From 81539762a088fbce8a480e478dcebdd571b92b7d Mon Sep 17 00:00:00 2001 From: sunrioa Date: Thu, 23 Jul 2026 22:32:00 +0800 Subject: [PATCH] feat: make proposal outcomes durable and game-authoritative --- README.en.md | 18 +- README.md | 14 +- adapters/renpy/rin_bridge.rpy | 89 +- adapters/renpy/rin_client.py | 929 ++++++- adapters/renpy/test_rin_client.py | 844 ++++++- compat/adapter_examples_test.go | 1016 +++++++- compat/documentation_test.go | 123 + compat/sdk_kits_test.go | 169 +- docs/README.md | 1 + docs/README.zh-CN.md | 1 + docs/architecture.md | 54 +- docs/architecture.zh-CN.md | 20 +- docs/game-adapters.md | 38 +- docs/game-adapters.zh-CN.md | 60 +- docs/outcome-reporting.md | 162 ++ docs/outcome-reporting.zh-CN.md | 133 + docs/protocol-v1.md | 89 +- docs/protocol-v1.zh-CN.md | 70 +- docs/rpg-events.md | 25 +- docs/rpg-events.zh-CN.md | 23 +- docs/sdk-and-mods.md | 23 +- docs/sdk-and-mods.zh-CN.md | 17 +- examples/basic/main.go | 1844 +++++++++++++- examples/basic/main_test.go | 2244 +++++++++++++++++ examples/godot/example_npc.gd | 1079 +++++++- examples/godot/rin_client.gd | 462 +++- examples/mods/bepinex-rin-npc/Plugin.cs | 1026 +++++++- examples/mods/bepinex-rin-npc/README.md | 33 +- examples/mods/bepinex-rin-npc/README.zh-CN.md | 27 +- examples/mods/fabric-rin-npc/README.md | 36 +- examples/mods/fabric-rin-npc/README.zh-CN.md | 29 +- .../github/sunrioa/rin/example/RinNpcMod.java | 1031 +++++++- examples/mods/luanti-rin-npc/README.md | 27 +- examples/mods/luanti-rin-npc/README.zh-CN.md | 26 +- examples/mods/luanti-rin-npc/init.lua | 690 ++++- examples/mods/luanti-rin-npc/rin.lua | 113 +- examples/unity/RinClient.cs | 886 ++++++- examples/unity/RinNpcExample.cs | 1272 +++++++++- httpapi/server_test.go | 218 ++ jobs/manager.go | 89 +- jobs/manager_test.go | 217 ++ protocol/features.go | 16 +- protocol/living.go | 4 + protocol/living_validate.go | 2 +- protocol/state_validate.go | 109 +- protocol/types.go | 32 +- protocol/validate.go | 53 +- protocol/validate_test.go | 107 + runtime/atomic_append_test.go | 1208 +++++++++ runtime/engine.go | 432 +++- runtime/engine_test.go | 634 ++++- runtime/living_cognition_test.go | 6 +- runtime/living_world_test.go | 76 +- runtime/outcome_merge_test.go | 824 ++++++ runtime/reducer.go | 329 ++- runtime/runtime.go | 11 + sdk/README.md | 9 +- sdk/README.zh-CN.md | 9 +- sdk/csharp/Rin.Client.Tests/Program.cs | 347 ++- sdk/csharp/Rin.Client/RinClient.cs | 247 +- .../java/io/github/sunrioa/rin/RinClient.java | 213 +- .../io/github/sunrioa/rin/RinClientTest.java | 162 +- sdk/javascript/src/index.d.ts | 2 + sdk/javascript/src/index.js | 99 +- sdk/javascript/test/client.test.js | 178 ++ sdk/lua/rin.lua | 113 +- sdk/lua/test_client.lua | 104 +- sdk/python/src/rin_sdk/client.py | 112 +- sdk/python/tests/test_client.py | 160 ++ store/file.go | 133 +- store/file_test.go | 207 ++ store/memory.go | 30 +- 72 files changed, 20255 insertions(+), 980 deletions(-) create mode 100644 docs/outcome-reporting.md create mode 100644 docs/outcome-reporting.zh-CN.md create mode 100644 examples/basic/main_test.go create mode 100644 runtime/atomic_append_test.go create mode 100644 runtime/outcome_merge_test.go diff --git a/README.en.md b/README.en.md index 7f7d9d6..0eb5d40 100644 --- a/README.en.md +++ b/README.en.md @@ -22,8 +22,8 @@ Rin separates character reasoning from game-world facts: - A character creates an `ActionProposal` from memories, goals, boundaries, and the actions currently allowed by the game. - A proposal cannot directly change plot, inventory, quests, or - relationships. It takes effect only after the game validates it and calls - `commit`. + relationships. The game validates and applies or rejects it, then uses + `commit` to report the actual outcome to Rin. - Every state change is written to a hash-chained JSONL event log that can be replayed and inspected. - Snapshots bind `game/content/version/hash`; tampered or mismatched saves are @@ -37,6 +37,11 @@ Rin separates character reasoning from game-world facts: the game. - If a model is unavailable, Rin falls back to a deterministic policy and identifies the source with `policy_source`. + +The apply-then-report lifecycle and late-outcome merge require new Sessions to +request `outcome-reporting-v1`. Sessions without that Feature retain the +legacy pre-commit/staleness behavior for replay compatibility. + - Ren'Py, Godot 4, and Unity adapters preserve the same observe/propose/commit authority boundary. - Python, JavaScript, C#, Java, and Lua SDKs plus Fabric, BepInEx, and Luanti @@ -99,8 +104,8 @@ additional persistence allowlist. | `POST` | `/v1/generation/jobs` | Submit an asynchronous structured JSON generation job | | `GET` | `/v1/generation/jobs/{job_id}` | Read a generation job and safe metadata | | `DELETE` | `/v1/generation/jobs/{job_id}` | Cancel a generation job | -| `POST` | `/v1/action/commit` | Accept or reject a proposal and record its outcome | -| `POST` | `/v1/action/commit-batch` | Atomically commit multi-actor outcomes at one world revision | +| `POST` | `/v1/action/commit` | Record an outcome the game already applied or rejected | +| `POST` | `/v1/action/commit-batch` | Atomically record multi-actor outcomes from one original world revision | | `POST` | `/v1/session/activity` | Update actor region and awake/dormant state | | `POST` | `/v1/world/arbitrate` | Deterministically arbitrate conflicting parallel proposals | | `POST` | `/v1/scheduler/due` | Query actors due to think at the current tick | @@ -115,8 +120,9 @@ request returns the same result without mutating state again. Reusing the same ID for another operation returns a conflict. See the [protocol reference](docs/protocol-v1.md) for complete fields and -error semantics, and the [architecture guide](docs/architecture.md) for -responsibility boundaries. +error semantics, the [architecture guide](docs/architecture.md) for +responsibility boundaries, and [action outcome reporting](docs/outcome-reporting.md) +for application, recording, and retry order. Inspect a session offline. The command verifies the log and prints only a redacted timeline: diff --git a/README.md b/README.md index b98e950..d0b83b6 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,17 @@ Rin 将“角色思考”和“游戏世界事实”拆开: - 游戏提交角色实际看见的 `Observation`,而不是把整个存档交给模型。 - 角色根据记忆、目标、边界和当前允许动作生成 `ActionProposal`。 -- 提案不能直接改变剧情、背包、任务或关系;游戏验证并调用 `commit` 后才生效。 +- 提案不能直接改变剧情、背包、任务或关系;游戏验证并应用或拒绝后, + 用 `commit` 向 Rin 回报实际结果。 - 每次状态变化写入带哈希链的 JSONL 事件日志,可重放、可检查。 - 快照绑定 `game/content/version/hash`,篡改或串档会被拒绝。 - 多 NPC 通过 tick 调度按需思考,不需要每帧调用模型。 - 在线模型通过异步 Job 预取,慢请求、取消和状态过期不会冻结游戏主线程。 - 通用结构化 Generation Job 让剧情、任务描述和受限对白也经过 Sidecar,而不是让游戏保存供应商 Key。 - 模型不可用时自动回退确定性 Policy,并用 `policy_source` 标明来源。 +- “游戏先处理、再回报”以及延迟结果合并要求新 Session 显式请求 + `outcome-reporting-v1`;未启用的 Session 为保持重放兼容,继续使用旧版 + Commit/stale 语义。 - Ren'Py、Godot 4 和 Unity 适配器保持同一套 observe / propose / commit 权威边界。 - Python、JavaScript、C#、Java、Lua SDK 与 Fabric、BepInEx、Luanti 示例 Mod 提供快速接入层。 - 可选分层记忆、冲突认知、候选小目标、区域休眠和确定性多角色仲裁均由 Session feature 显式启用。 @@ -75,8 +79,8 @@ go run ./cmd/rin serve | `POST` | `/v1/generation/jobs` | 异步提交结构化 JSON 生成任务 | | `GET` | `/v1/generation/jobs/{job_id}` | 查询生成任务与安全元数据 | | `DELETE` | `/v1/generation/jobs/{job_id}` | 取消生成任务 | -| `POST` | `/v1/action/commit` | 接受或拒绝提案并记录结果 | -| `POST` | `/v1/action/commit-batch` | 原子提交同一世界版本的多角色结果 | +| `POST` | `/v1/action/commit` | 记录游戏已经应用或拒绝的实际结果 | +| `POST` | `/v1/action/commit-batch` | 原子记录同一原始世界版本的多角色结果 | | `POST` | `/v1/session/activity` | 更新角色区域与 awake/dormant 状态 | | `POST` | `/v1/world/arbitrate` | 对并行角色提案进行确定性冲突仲裁 | | `POST` | `/v1/scheduler/due` | 查询当前 tick 应思考的角色 | @@ -88,7 +92,9 @@ go run ./cmd/rin serve 所有写请求都带调用方生成的 `request_id`,重复请求返回相同结果,不重复修改状态。同一 ID 被用于不同操作时返回冲突。 -完整字段和错误语义见 [协议文档](docs/protocol-v1.zh-CN.md),职责边界见 [架构文档](docs/architecture.zh-CN.md)。 +完整字段和错误语义见 [协议文档](docs/protocol-v1.zh-CN.md),职责边界见 +[架构文档](docs/architecture.zh-CN.md),应用、结果记账和重试顺序见 +[动作结果记账](docs/outcome-reporting.zh-CN.md)。 离线检查一个会话(会验证日志并只打印脱敏时间线): diff --git a/adapters/renpy/rin_bridge.rpy b/adapters/renpy/rin_bridge.rpy index a525947..f6546b8 100644 --- a/adapters/renpy/rin_bridge.rpy +++ b/adapters/renpy/rin_bridge.rpy @@ -12,6 +12,7 @@ init -30 python: _RIN_REGISTRY = None _RIN_CONFIG_FINGERPRINT = None _RIN_LOCAL_RESULTS = {} + _RIN_UNRESOLVED_ATTEMPTS = {} def _rin_env_enabled(name, default="0"): value = os.environ.get(name, default).strip().lower() @@ -91,8 +92,31 @@ init -30 python: )), } - def rin_schedule_proposal(request, fallback_action_id=""): + def _rin_store_unresolved_attempt(request_id, request, fallback_action_id, job_id, error_code): + _RIN_UNRESOLVED_ATTEMPTS[str(request_id)] = { + "status": "unresolved", + "request_fingerprint": _rin_request_fingerprint(request), + "request": json.loads(json.dumps( + request, + ensure_ascii=False, + separators=(",", ":"), + )), + "fallback_action_id": str(fallback_action_id), + "job_id": str(job_id or ""), + "error_code": str(error_code or "job_outcome_unknown"), + "allow_offline_before_submit": False, + } + + def rin_schedule_proposal( + request, + fallback_action_id="", + known_job_id="", + resuming=False, + allow_offline_before_submit=True, + ): """Start one proposal without blocking the Ren'Py interaction thread.""" + resuming = bool(resuming) + allow_offline_before_submit = bool(allow_offline_before_submit) and not resuming request_id = str(request.get("request_id", "")) if not request_id: raise rin_client.RinProtocolError("invalid_request", "Proposal request needs request_id") @@ -103,8 +127,33 @@ init -30 python: "Request id was already used with a different proposal payload", ) return request_id + retained = _RIN_UNRESOLVED_ATTEMPTS.get(request_id) + if retained is not None: + if retained["request_fingerprint"] != _rin_request_fingerprint(request): + raise rin_client.RinProtocolError( + "request_id_conflict", + "Request id was already used with a different proposal payload", + ) + request = retained["request"] + fallback_action_id = retained["fallback_action_id"] + known_job_id = retained["job_id"] + resuming = True + allow_offline_before_submit = False client, registry, disabled_reason = _rin_runtime() if registry is None: + if resuming or not allow_offline_before_submit or known_job_id: + _rin_store_unresolved_attempt( + request_id, + request, + fallback_action_id, + known_job_id, + disabled_reason or ( + "job_outcome_unknown" + if known_job_id + else "proposal_outcome_unknown" + ), + ) + return request_id _rin_store_local_result( request_id, request, @@ -116,18 +165,24 @@ init -30 python: ) return request_id config = _rin_config() - return registry.schedule( + scheduled = registry.schedule( request, renpy.invoke_in_thread, fallback_action_id=fallback_action_id, deadline_seconds=config["deadline"], poll_interval=config["poll_interval"], + known_job_id=known_job_id, + allow_offline_before_submit=allow_offline_before_submit, ) + _RIN_UNRESOLVED_ATTEMPTS.pop(request_id, None) + return scheduled def rin_proposal_status(request_id): request_id = str(request_id) if request_id in _RIN_LOCAL_RESULTS: return "ready" + if request_id in _RIN_UNRESOLVED_ATTEMPTS: + return "unresolved" if _RIN_REGISTRY is None: return "missing" status = _RIN_REGISTRY.status(request_id) @@ -141,6 +196,8 @@ init -30 python: local = _RIN_LOCAL_RESULTS.pop(request_id, None) if local is not None: return local["result"] + if request_id in _RIN_UNRESOLVED_ATTEMPTS: + return None if _RIN_REGISTRY is None: return None entry = _RIN_REGISTRY.consume(request_id) @@ -152,15 +209,39 @@ init -30 python: "source": "canceled" if entry["status"] == "canceled" else "error", "committable": False, "fallback_reason": entry["error_code"], - "job_id": "", + "job_id": entry.get("job_id", ""), "proposal": None, } + def rin_proposal_attempt(request_id): + """Return a plain pending/unresolved record suitable for game persistence.""" + request_id = str(request_id) + retained = _RIN_UNRESOLVED_ATTEMPTS.get(request_id) + if retained is not None: + return json.loads(json.dumps(retained, ensure_ascii=False, separators=(",", ":"))) + if _RIN_REGISTRY is None: + return None + return _RIN_REGISTRY.attempt(request_id) + + def rin_resume_proposal(attempt): + """Resume a game-persisted attempt with its exact request and known Job.""" + if not isinstance(attempt, dict) or not isinstance(attempt.get("request"), dict): + raise rin_client.RinProtocolError("invalid_attempt", "Proposal attempt is invalid") + return rin_schedule_proposal( + attempt["request"], + fallback_action_id=str(attempt.get("fallback_action_id", "")), + known_job_id=str(attempt.get("job_id", "")), + resuming=True, + allow_offline_before_submit=False, + ) + def rin_cancel_proposal(request_id): request_id = str(request_id) if request_id in _RIN_LOCAL_RESULTS: _RIN_LOCAL_RESULTS.pop(request_id, None) return True + if request_id in _RIN_UNRESOLVED_ATTEMPTS: + return False if _RIN_REGISTRY is None: return False return _RIN_REGISTRY.cancel(request_id) @@ -171,5 +252,5 @@ init -30 python: "enabled": _rin_transport_enabled(), "base_url": config["base_url"], "token_configured": bool(config["token"]), - "pending_results": len(_RIN_LOCAL_RESULTS), + "pending_results": len(_RIN_LOCAL_RESULTS) + len(_RIN_UNRESOLVED_ATTEMPTS), } diff --git a/adapters/renpy/rin_client.py b/adapters/renpy/rin_client.py index f5bdf35..c7a9454 100644 --- a/adapters/renpy/rin_client.py +++ b/adapters/renpy/rin_client.py @@ -7,6 +7,7 @@ from __future__ import annotations +import errno import hashlib import ipaddress import json @@ -22,12 +23,21 @@ PROTOCOL_VERSION = "rin.protocol/v1" DEFAULT_BASE_URL = "http://127.0.0.1:7374" DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024 +MAX_GENERATION_CONTENT_BYTES = 4 * 1024 * 1024 +MAX_INT64 = (1 << 63) - 1 TERMINAL_JOB_STATES = frozenset(( "succeeded", "failed", "stale", "canceled", )) +UNRESOLVED_PROPOSAL_CODES = frozenset(( + "proposal_outcome_unknown", + "job_outcome_unknown", + "job_cancel_unconfirmed", + "job_id_persistence_failed", + "job_timeout", +)) class RinError(RuntimeError): @@ -66,7 +76,17 @@ def __init__( class RinJobError(RinAPIError): - pass + def __init__( + self, + code: str, + message: str, + *, + status: int = 0, + field: str = "", + job_id: str = "", + ) -> None: + self.job_id = _safe_text(job_id, 96) + super().__init__(code, message, status=status, field=field) class _NoRedirectHandler(HTTPRedirectHandler): @@ -207,15 +227,23 @@ def submit_generation_job(self, request: Dict[str, Any]) -> Dict[str, Any]: return self._request("POST", "/v1/generation/jobs", request, expected_statuses=(202,)) def get_generation_job(self, job_id: str) -> Dict[str, Any]: - return self._request("GET", "/v1/generation/jobs/" + _path_identifier(job_id)) + expected_job_id = _path_identifier(job_id) + job = self._request("GET", "/v1/generation/jobs/" + expected_job_id) + _validate_generation_job_shape(job, expected_job_id) + return job def cancel_generation_job(self, job_id: str) -> Dict[str, Any]: - return self._request("DELETE", "/v1/generation/jobs/" + _path_identifier(job_id)) + expected_job_id = _path_identifier(job_id) + job = self._request("DELETE", "/v1/generation/jobs/" + expected_job_id) + _validate_generation_job_shape(job, expected_job_id) + return job def commit(self, request: Dict[str, Any]) -> Dict[str, Any]: + """Report a game-applied or rejected outcome; this does not execute it.""" return self._request("POST", "/v1/action/commit", request) def commit_batch(self, request: Dict[str, Any]) -> Dict[str, Any]: + """Atomically report game outcomes produced from one world revision.""" return self._request("POST", "/v1/action/commit-batch", request) def set_actor_activity(self, request: Dict[str, Any]) -> Dict[str, Any]: @@ -249,6 +277,7 @@ def wait_for_proposal( deadline_seconds: float = 25.0, poll_interval: float = 0.1, cancel_event: Optional[threading.Event] = None, + expected_request: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: deadline_seconds = float(deadline_seconds) poll_interval = float(poll_interval) @@ -256,39 +285,215 @@ def wait_for_proposal( raise RinConfigurationError("invalid_deadline", "Job deadline must be between 0.05 and 300 seconds") if not 0.01 <= poll_interval <= 5.0: raise RinConfigurationError("invalid_poll_interval", "Job poll interval must be between 0.01 and 5 seconds") + job_id = _path_identifier(job_id) + if expected_request is not None: + expected_request = _stable_proposal_request(expected_request) deadline = self._clock() + deadline_seconds while True: if cancel_event is not None and cancel_event.is_set(): - self._cancel_quietly(job_id) - raise RinJobError("job_canceled", "Proposal job was canceled") - job = self.get_proposal_job(job_id) - status = str(job.get("status", "")) - if status == "succeeded": - proposal = job.get("proposal") - if not isinstance(proposal, dict): - raise RinProtocolError("invalid_job", "Successful proposal job did not include a proposal") - return job - if status in TERMINAL_JOB_STATES: - detail = job.get("error", {}) - if not isinstance(detail, dict): - detail = {} + try: + canceled_job = self.cancel_proposal_job(job_id) + except RinError: + raise RinJobError( + "job_cancel_unconfirmed", + "Proposal job cancellation could not be confirmed", + job_id=job_id, + ) from None + resolved = self._resolve_proposal_job_or_unknown( + canceled_job, + job_id, + expected_request, + ) + if resolved is not None: + return resolved raise RinJobError( - _safe_text(detail.get("code"), 96) or "job_" + status, - _safe_text(detail.get("message"), 500) or "Proposal job ended as " + status, - field=_safe_text(detail.get("field"), 160), + "job_cancel_unconfirmed", + "Proposal job cancellation did not reach a terminal state", + job_id=job_id, ) - if status not in ("queued", "running"): - raise RinProtocolError("invalid_job", "Proposal job returned an unknown status") + try: + job = self.get_proposal_job(job_id) + except RinAPIError as exc: + if exc.code == "job_not_found": + raise + raise RinJobError( + "job_outcome_unknown", + "Proposal job lookup did not confirm an outcome", + status=exc.status, + field=exc.field, + job_id=job_id, + ) from exc + except RinError as exc: + raise RinJobError( + "job_outcome_unknown", + "Proposal job lookup did not confirm an outcome", + job_id=job_id, + ) from exc + resolved = self._resolve_proposal_job_or_unknown( + job, + job_id, + expected_request, + ) + if resolved is not None: + return resolved remaining = deadline - self._clock() if remaining <= 0: - self._cancel_quietly(job_id) - raise RinJobError("job_timeout", "Proposal job exceeded its deadline") + try: + canceled_job = self.cancel_proposal_job(job_id) + except RinError: + raise RinJobError( + "job_outcome_unknown", + "Proposal deadline elapsed and cancellation could not be confirmed", + job_id=job_id, + ) from None + resolved = self._resolve_proposal_job_or_unknown( + canceled_job, + job_id, + expected_request, + ) + if resolved is not None: + return resolved + raise RinJobError( + "job_outcome_unknown", + "Proposal deadline elapsed without a terminal cancellation result", + job_id=job_id, + ) delay = min(poll_interval, remaining) if cancel_event is not None: cancel_event.wait(delay) else: self._sleeper(delay) + @staticmethod + def _resolve_proposal_job( + job: Dict[str, Any], + job_id: str = "", + expected_request: Optional[Dict[str, Any]] = None, + ) -> Optional[Dict[str, Any]]: + if not isinstance(job, dict): + raise RinProtocolError("invalid_job", "Rin returned an invalid proposal job") + _validate_proposal_job_identity(job, job_id, expected_request) + status = job.get("status") + if not isinstance(status, str): + raise RinProtocolError("invalid_job", "Proposal job status must be a string") + if status == "succeeded": + if expected_request is None: + # Preserve the public wait_for_proposal(job_id) API. Without + # the original request it cannot prove the selected candidate, + # but it can still require a self-consistent Job/Proposal pair. + _validate_unbound_proposal_identity(job.get("proposal"), job) + else: + _validate_proposal_identity(job.get("proposal"), expected_request) + return job + if status in TERMINAL_JOB_STATES: + detail = job.get("error", {}) + if not isinstance(detail, dict): + detail = {} + if status == "failed": + error_code = detail.get("code") + if not isinstance(error_code, str): + raise RinProtocolError( + "invalid_job", + "Failed proposal job error code must be a string", + ) + try: + error_code = _path_identifier(error_code) + except RinProtocolError as exc: + raise RinProtocolError( + "invalid_job", + "Failed proposal job error code is invalid", + ) from exc + else: + error_code = _safe_text(detail.get("code"), 96) or "job_" + status + raise RinJobError( + error_code, + _safe_text(detail.get("message"), 500) or "Proposal job ended as " + status, + field=_safe_text(detail.get("field"), 160), + job_id=job_id, + ) + if status not in ("queued", "running"): + raise RinProtocolError("invalid_job", "Proposal job returned an unknown status") + return None + + @classmethod + def _resolve_proposal_job_or_unknown( + cls, + job: Dict[str, Any], + job_id: str, + expected_request: Optional[Dict[str, Any]] = None, + ) -> Optional[Dict[str, Any]]: + try: + return cls._resolve_proposal_job(job, job_id, expected_request) + except RinJobError: + raise + except RinProtocolError as exc: + raise RinJobError( + "job_outcome_unknown", + "Proposal job returned an invalid outcome", + job_id=job_id, + ) from exc + + def _submit_proposal_attempt( + self, + request: Dict[str, Any], + persist_job_id: Optional[Callable[[str], bool]], + previous_job_id: str, + allow_offline_before_submit: bool = True, + ) -> str: + try: + submission = self.submit_proposal_job(request) + except RinTransportError as exc: + if ( + allow_offline_before_submit + and not previous_job_id + and exc.code == "transport_unavailable" + ): + raise + raise RinJobError( + "job_outcome_unknown" if previous_job_id else "proposal_outcome_unknown", + "Proposal submission did not confirm a durable Job", + job_id=previous_job_id, + ) from exc + except RinAPIError as exc: + if 400 <= exc.status < 500 and exc.status != 408: + # A bounded client error from Rin confirms that no new Job was + # accepted. Gateway/server failures remain delivery-ambiguous. + raise + raise RinJobError( + "job_outcome_unknown" if previous_job_id else "proposal_outcome_unknown", + "Proposal submission did not confirm a durable Job", + status=exc.status, + field=exc.field, + job_id=previous_job_id, + ) from exc + except RinError as exc: + raise RinJobError( + "job_outcome_unknown" if previous_job_id else "proposal_outcome_unknown", + "Proposal submission returned an invalid or ambiguous response", + job_id=previous_job_id, + ) from exc + job_id = str(submission.get("job_id", "")) + try: + job_id = _path_identifier(job_id) + except RinProtocolError as exc: + raise RinJobError( + "job_outcome_unknown" if previous_job_id else "proposal_outcome_unknown", + "Proposal submission did not return a usable Job identity", + job_id=previous_job_id, + ) from exc + if persist_job_id is not None: + try: + persisted = bool(persist_job_id(job_id)) + except Exception: + persisted = False + if not persisted: + raise RinJobError( + "job_id_persistence_failed", + "Proposal Job identity could not be retained", + job_id=job_id, + ) + return job_id + def propose_with_fallback( self, request: Dict[str, Any], @@ -297,28 +502,88 @@ def propose_with_fallback( deadline_seconds: float = 25.0, poll_interval: float = 0.1, cancel_event: Optional[threading.Event] = None, + known_job_id: str = "", + persist_job_id: Optional[Callable[[str], bool]] = None, + allow_offline_before_submit: bool = True, ) -> Dict[str, Any]: - job_id = "" + request = _stable_proposal_request(request) + try: + job_id = _path_identifier(known_job_id) if known_job_id else "" + except RinProtocolError as exc: + raise RinJobError( + "job_outcome_unknown", + "The retained Proposal Job identity is invalid", + job_id=_safe_text(known_job_id, 96), + ) from exc + recovery_post_used = False try: - submission = self.submit_proposal_job(request) - job_id = str(submission.get("job_id", "")) if not job_id: - raise RinProtocolError("invalid_submission", "Rin did not return a proposal job id") - job = self.wait_for_proposal( - job_id, - deadline_seconds=deadline_seconds, - poll_interval=poll_interval, - cancel_event=cancel_event, - ) - return { - "source": "sidecar", - "committable": True, - "fallback_reason": "", - "job_id": job_id, - "proposal": _json_clone(job["proposal"]), - } + job_id = self._submit_proposal_attempt( + request, + persist_job_id, + "", + allow_offline_before_submit=allow_offline_before_submit, + ) + while True: + try: + job = self.wait_for_proposal( + job_id, + deadline_seconds=deadline_seconds, + poll_interval=poll_interval, + cancel_event=cancel_event, + expected_request=request, + ) + except RinJobError as exc: + if ( + exc.code == "proposal_outcome_unknown" + and not recovery_post_used + and not (cancel_event is not None and cancel_event.is_set()) + ): + recovery_post_used = True + job_id = self._submit_proposal_attempt( + request, + persist_job_id, + exc.job_id or job_id, + allow_offline_before_submit=False, + ) + continue + raise + except RinAPIError as exc: + if ( + exc.code == "job_not_found" + and not recovery_post_used + and not (cancel_event is not None and cancel_event.is_set()) + ): + recovery_post_used = True + job_id = self._submit_proposal_attempt( + request, + persist_job_id, + job_id, + allow_offline_before_submit=False, + ) + continue + raise RinJobError( + "job_outcome_unknown", + "Proposal Job could not be recovered", + job_id=job_id, + ) from exc + return { + "source": "sidecar", + "committable": True, + "fallback_reason": "", + "job_id": job_id, + "proposal": _json_clone(job["proposal"]), + } except RinJobError as exc: - if exc.code == "job_canceled": + job_id = exc.job_id or job_id + if (cancel_event is not None and cancel_event.is_set()) or exc.code in { + "job_canceled", + "job_cancel_unconfirmed", + "job_outcome_unknown", + "job_id_persistence_failed", + "job_timeout", + "proposal_outcome_unknown", + }: raise return offline_proposal_result( request, @@ -326,12 +591,25 @@ def propose_with_fallback( reason=exc.code, job_id=job_id, ) - except RinError as exc: + except RinAPIError: + # An HTTP error after POST began may have been produced by a + # reverse proxy after Rin durably created the Job (notably + # 502/504). Only a transport error proven to occur before delivery + # can authorize the local fallback below. + raise + except RinTransportError as exc: + if ( + (cancel_event is not None and cancel_event.is_set()) + or job_id + or not allow_offline_before_submit + or exc.code != "transport_unavailable" + ): + raise return offline_proposal_result( request, fallback_action_id=fallback_action_id, reason=exc.code, - job_id=job_id, + job_id="", ) def wait_for_generation( @@ -348,39 +626,76 @@ def wait_for_generation( raise RinConfigurationError("invalid_deadline", "Generation deadline must be between 0.05 and 300 seconds") if not 0.01 <= poll_interval <= 5.0: raise RinConfigurationError("invalid_poll_interval", "Generation poll interval must be between 0.01 and 5 seconds") + job_id = _path_identifier(job_id) deadline = self._clock() + deadline_seconds while True: if cancel_event is not None and cancel_event.is_set(): - self._cancel_generation_quietly(job_id) - raise RinJobError("job_canceled", "Generation job was canceled") - job = self.get_generation_job(job_id) - status = str(job.get("status", "")) - if status == "succeeded": - result = job.get("result") - if not isinstance(result, dict) or not isinstance(result.get("content"), str): - raise RinProtocolError("invalid_job", "Successful generation job did not include content") - return job - if status in TERMINAL_JOB_STATES: - detail = job.get("error", {}) - if not isinstance(detail, dict): - detail = {} + try: + canceled_job = self.cancel_generation_job(job_id) + except RinError: + raise RinJobError( + "job_cancel_unconfirmed", + "Generation job cancellation could not be confirmed", + job_id=job_id, + ) from None + resolved = self._resolve_generation_job(canceled_job, job_id) + if resolved is not None: + return resolved raise RinJobError( - _safe_text(detail.get("code"), 96) or "job_" + status, - _safe_text(detail.get("message"), 500) or "Generation job ended as " + status, - field=_safe_text(detail.get("field"), 160), + "job_cancel_unconfirmed", + "Generation job cancellation did not reach a terminal state", + job_id=job_id, ) - if status not in ("queued", "running"): - raise RinProtocolError("invalid_job", "Generation job returned an unknown status") + job = self.get_generation_job(job_id) + resolved = self._resolve_generation_job(job, job_id) + if resolved is not None: + return resolved remaining = deadline - self._clock() if remaining <= 0: - self._cancel_generation_quietly(job_id) - raise RinJobError("job_timeout", "Generation job exceeded its deadline") + try: + canceled_job = self.cancel_generation_job(job_id) + except RinError: + raise RinJobError( + "job_timeout", + "Generation job exceeded its deadline", + job_id=job_id, + ) from None + resolved = self._resolve_generation_job(canceled_job, job_id) + if resolved is not None: + return resolved + raise RinJobError( + "job_timeout", + "Generation job exceeded its deadline", + job_id=job_id, + ) delay = min(poll_interval, remaining) if cancel_event is not None: cancel_event.wait(delay) else: self._sleeper(delay) + @staticmethod + def _resolve_generation_job( + job: Dict[str, Any], + expected_job_id: str, + ) -> Optional[Dict[str, Any]]: + if not isinstance(job, dict): + raise RinProtocolError("invalid_job", "Rin returned an invalid generation job") + status = _validate_generation_job_shape(job, expected_job_id) + if status == "succeeded": + return job + if status in TERMINAL_JOB_STATES: + detail = job.get("error", {}) + if not isinstance(detail, dict): + detail = {} + raise RinJobError( + _safe_text(detail.get("code"), 96) or "job_" + status, + _safe_text(detail.get("message"), 500) or "Generation job ended as " + status, + field=_safe_text(detail.get("field"), 160), + job_id=expected_job_id, + ) + return None + def generate_json( self, request: Dict[str, Any], @@ -401,7 +716,10 @@ def generate_json( ) result = _json_clone(job["result"]) try: - response = json.loads(result["content"]) + response = json.loads( + result["content"], + parse_constant=_reject_json_constant, + ) except (TypeError, ValueError) as exc: raise RinProtocolError("invalid_generation_json", "Rin generation content was not valid JSON") from exc if not isinstance(response, dict): @@ -413,18 +731,6 @@ def generate_json( "metadata": {key: value for key, value in result.items() if key != "content"}, } - def _cancel_quietly(self, job_id: str) -> None: - try: - self.cancel_proposal_job(job_id) - except RinError: - pass - - def _cancel_generation_quietly(self, job_id: str) -> None: - try: - self.cancel_generation_job(job_id) - except RinError: - pass - def _request( self, method: str, @@ -438,11 +744,17 @@ def _request( if payload is not None: if not isinstance(payload, dict): raise RinProtocolError("invalid_request", "Rin request payload must be an object") - body = json.dumps( - payload, - ensure_ascii=False, - separators=(",", ":"), - ).encode("utf-8") + try: + body = json.dumps( + payload, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + except (TypeError, ValueError) as exc: + raise RinProtocolError( + "invalid_request", + "Rin request payload is not JSON serializable", + ) from exc headers["Content-Type"] = "application/json" if self.token: headers["Authorization"] = "Bearer " + self.token @@ -459,11 +771,20 @@ def _request( raise RinTransportError("transport_timeout", "Rin request timed out") from exc except URLError as exc: reason = getattr(exc, "reason", None) - code = "transport_timeout" if isinstance(reason, (socket.timeout, TimeoutError)) else "transport_failed" - message = "Rin request timed out" if code == "transport_timeout" else "Could not reach the Rin Sidecar" + if isinstance(reason, (socket.timeout, TimeoutError)): + code = "transport_timeout" + message = "Rin request timed out" + elif _definitely_not_delivered(reason): + code = "transport_unavailable" + message = "Could not connect to the Rin Sidecar" + else: + code = "transport_failed" + message = "Rin transport failed after delivery became uncertain" raise RinTransportError(code, message) from exc except OSError as exc: - raise RinTransportError("transport_failed", "Could not reach the Rin Sidecar") from exc + if _definitely_not_delivered(exc): + raise RinTransportError("transport_unavailable", "Could not connect to the Rin Sidecar") from exc + raise RinTransportError("transport_failed", "Rin transport failed after delivery became uncertain") from exc if status not in expected_statuses: decoded = _decode_json(response_payload, allow_failure=True) raise _error_from_envelope(decoded, status) @@ -503,6 +824,14 @@ def _decode_json(payload: bytes, *, allow_failure: bool = False) -> Dict[str, An return decoded +def _definitely_not_delivered(reason: Any) -> bool: + return isinstance(reason, OSError) and getattr(reason, "errno", None) in { + errno.ECONNREFUSED, + errno.ENETUNREACH, + errno.EHOSTUNREACH, + } + + def _path_identifier(value: str) -> str: text = str(value or "") if not text or len(text) > 96 or not text[0].isalnum(): @@ -512,6 +841,335 @@ def _path_identifier(value: str) -> str: return text +def _reject_json_constant(value: str) -> None: + raise ValueError("Non-finite JSON number is not permitted: " + value) + + +def _strict_nonnegative_int64(value: Any) -> bool: + return ( + isinstance(value, int) + and not isinstance(value, bool) + and 0 <= value <= MAX_INT64 + ) + + +def _normalized_action_spec(value: Any, *, field: str) -> Dict[str, Any]: + if not isinstance(value, dict): + raise RinProtocolError("invalid_job", field + " must be an object") + allowed = {"id", "kind", "description", "target_ids", "parameters"} + if any(key not in allowed for key in value): + raise RinProtocolError("invalid_job", field + " contains an unknown field") + + action_id = value.get("id") + kind = value.get("kind") + description = value.get("description") + if not isinstance(action_id, str) or not isinstance(kind, str): + raise RinProtocolError("invalid_job", field + " must include string id and kind") + try: + action_id = _path_identifier(action_id) + kind = _path_identifier(kind) + except RinProtocolError as exc: + raise RinProtocolError("invalid_job", field + " contains an invalid id or kind") from exc + if ( + not isinstance(description, str) + or not description.strip() + or len(description) > 300 + or "\x00" in description + ): + raise RinProtocolError("invalid_job", field + " contains an invalid description") + + target_ids = value.get("target_ids", []) + if not isinstance(target_ids, list) or len(target_ids) > 32: + raise RinProtocolError("invalid_job", field + " contains invalid target ids") + normalized_targets = [] + for target_id in target_ids: + if not isinstance(target_id, str): + raise RinProtocolError("invalid_job", field + " contains a non-string target id") + try: + normalized_targets.append(_path_identifier(target_id)) + except RinProtocolError as exc: + raise RinProtocolError("invalid_job", field + " contains an invalid target id") from exc + + parameters = value.get("parameters", {}) + if not isinstance(parameters, dict) or len(parameters) > 32: + raise RinProtocolError("invalid_job", field + " contains invalid parameters") + normalized_parameters = {} + for key, parameter in parameters.items(): + if not isinstance(key, str) or not isinstance(parameter, str): + raise RinProtocolError("invalid_job", field + " contains a non-string parameter") + try: + normalized_key = _path_identifier(key) + except RinProtocolError as exc: + raise RinProtocolError("invalid_job", field + " contains an invalid parameter key") from exc + if len(parameter) > 500 or "\x00" in parameter: + raise RinProtocolError("invalid_job", field + " contains an invalid parameter value") + normalized_parameters[normalized_key] = parameter + + return { + "id": action_id, + "kind": kind, + "description": description, + "target_ids": normalized_targets, + "parameters": normalized_parameters, + } + + +def _stable_proposal_request(request: Any) -> Dict[str, Any]: + if not isinstance(request, dict): + raise RinProtocolError("invalid_request", "Proposal request must be an object") + try: + stable = _json_clone(request) + except (TypeError, ValueError) as exc: + raise RinProtocolError( + "invalid_request", + "Proposal request must be JSON serializable", + ) from exc + + for field in ("session_id", "request_id", "actor_id"): + value = stable.get(field) + if not isinstance(value, str): + raise RinProtocolError("invalid_request", field + " must be a string") + try: + _path_identifier(value) + except RinProtocolError as exc: + raise RinProtocolError("invalid_request", field + " is invalid") from exc + if not _strict_nonnegative_int64(stable.get("tick")): + raise RinProtocolError( + "invalid_request", + "tick must be a non-negative signed 64-bit integer", + ) + actions = stable.get("candidate_actions") + if not isinstance(actions, list) or not 1 <= len(actions) <= 32: + raise RinProtocolError( + "invalid_request", + "candidate_actions must contain 1-32 actions", + ) + try: + normalized = [ + _normalized_action_spec(action, field="candidate_actions") + for action in actions + ] + except RinProtocolError as exc: + raise RinProtocolError("invalid_request", exc.safe_message) from exc + if len({action["id"] for action in normalized}) != len(normalized): + raise RinProtocolError( + "invalid_request", + "candidate_actions must have unique ids", + ) + return stable + + +def _validate_proposal_job_identity( + job: Dict[str, Any], + expected_job_id: str, + expected_request: Optional[Dict[str, Any]], +) -> None: + actual_job_id = job.get("job_id") + if not isinstance(actual_job_id, str): + raise RinProtocolError("invalid_job", "Proposal job id must be a string") + try: + actual_job_id = _path_identifier(actual_job_id) + except RinProtocolError as exc: + raise RinProtocolError("invalid_job", "Proposal job id is invalid") from exc + if actual_job_id != expected_job_id: + raise RinProtocolError("invalid_job", "Proposal job id did not match the requested job") + + for field in ("session_id", "request_id"): + actual = job.get(field) + if not isinstance(actual, str): + raise RinProtocolError("invalid_job", "Proposal job " + field + " must be a string") + try: + _path_identifier(actual) + except RinProtocolError as exc: + raise RinProtocolError("invalid_job", "Proposal job " + field + " is invalid") from exc + if expected_request is not None and actual != expected_request.get(field): + raise RinProtocolError( + "invalid_job", + "Proposal job " + field + " did not match the stable request", + ) + + +def _validate_proposal_identity( + proposal: Any, + expected_request: Dict[str, Any], +) -> None: + if not isinstance(proposal, dict): + raise RinProtocolError( + "invalid_job", + "Successful proposal job did not include a proposal", + ) + proposal_id = proposal.get("id") + if not isinstance(proposal_id, str): + raise RinProtocolError("invalid_job", "Proposal id must be a string") + try: + _path_identifier(proposal_id) + except RinProtocolError as exc: + raise RinProtocolError("invalid_job", "Proposal id is invalid") from exc + + for field in ("session_id", "request_id", "actor_id"): + actual = proposal.get(field) + if not isinstance(actual, str) or actual != expected_request.get(field): + raise RinProtocolError( + "invalid_job", + "Proposal " + field + " did not match the stable request", + ) + tick = proposal.get("tick") + if ( + not _strict_nonnegative_int64(tick) + or tick != expected_request.get("tick") + ): + raise RinProtocolError( + "invalid_job", + "Proposal tick did not match the stable request", + ) + + action = _normalized_action_spec(proposal.get("action"), field="proposal.action") + expected_actions = [ + _normalized_action_spec(candidate, field="candidate_actions") + for candidate in expected_request["candidate_actions"] + ] + if action not in expected_actions: + raise RinProtocolError( + "invalid_job", + "Proposal action did not exactly match a candidate action", + ) + + +def _validate_unbound_proposal_identity( + proposal: Any, + job: Dict[str, Any], +) -> None: + if not isinstance(proposal, dict): + raise RinProtocolError( + "invalid_job", + "Successful proposal job did not include a proposal", + ) + proposal_id = proposal.get("id") + if not isinstance(proposal_id, str): + raise RinProtocolError("invalid_job", "Proposal id must be a string") + try: + _path_identifier(proposal_id) + except RinProtocolError as exc: + raise RinProtocolError("invalid_job", "Proposal id is invalid") from exc + + for field in ("session_id", "request_id"): + actual = proposal.get(field) + if not isinstance(actual, str) or actual != job.get(field): + raise RinProtocolError( + "invalid_job", + "Proposal " + field + " did not match its Job", + ) + actor_id = proposal.get("actor_id") + if not isinstance(actor_id, str): + raise RinProtocolError("invalid_job", "Proposal actor_id must be a string") + try: + _path_identifier(actor_id) + except RinProtocolError as exc: + raise RinProtocolError("invalid_job", "Proposal actor_id is invalid") from exc + if not _strict_nonnegative_int64(proposal.get("tick")): + raise RinProtocolError( + "invalid_job", + "Proposal tick must be a non-negative signed 64-bit integer", + ) + _normalized_action_spec(proposal.get("action"), field="proposal.action") + + +def _validate_generation_job_identity( + job: Dict[str, Any], + expected_job_id: str, +) -> None: + actual_job_id = job.get("job_id") + if not isinstance(actual_job_id, str): + raise RinProtocolError("invalid_job", "Generation job id must be a string") + try: + actual_job_id = _path_identifier(actual_job_id) + except RinProtocolError as exc: + raise RinProtocolError("invalid_job", "Generation job id is invalid") from exc + if actual_job_id != expected_job_id: + raise RinProtocolError( + "invalid_job", + "Generation job id did not match the requested job", + ) + request_id = job.get("request_id") + if not isinstance(request_id, str): + raise RinProtocolError( + "invalid_job", + "Generation job request id must be a string", + ) + try: + _path_identifier(request_id) + except RinProtocolError as exc: + raise RinProtocolError( + "invalid_job", + "Generation job request id is invalid", + ) from exc + + +def _validate_generation_job_shape( + job: Dict[str, Any], + expected_job_id: str, +) -> str: + if not isinstance(job, dict): + raise RinProtocolError("invalid_job", "Rin returned an invalid generation job") + _validate_generation_job_identity(job, expected_job_id) + status = job.get("status") + if not isinstance(status, str) or status not in ( + "queued", + "running", + "succeeded", + "failed", + "stale", + "canceled", + ): + raise RinProtocolError("invalid_job", "Generation job returned an invalid status") + if status == "succeeded": + _validate_generation_result(job.get("result")) + return status + + +def _validate_generation_result(result: Any) -> None: + if not isinstance(result, dict) or not isinstance(result.get("content"), str): + raise RinProtocolError( + "invalid_job", + "Successful generation job did not include string content", + ) + content = result["content"] + if not content.strip() or "\x00" in content: + raise RinProtocolError( + "invalid_job", + "Successful generation job content is empty or contains NUL", + ) + try: + encoded_content = content.encode("utf-8") + except UnicodeEncodeError as exc: + raise RinProtocolError( + "invalid_job", + "Successful generation job content is not valid UTF-8", + ) from exc + if len(encoded_content) > MAX_GENERATION_CONTENT_BYTES: + raise RinProtocolError( + "invalid_job", + "Successful generation job content exceeds 4 MiB", + ) + for field in ("model", "finish_reason"): + if field in result and not isinstance(result[field], str): + raise RinProtocolError( + "invalid_job", + "Generation result " + field + " must be a string", + ) + for field in ("prompt_tokens", "output_tokens", "total_tokens"): + if field in result and not _strict_nonnegative_int64(result[field]): + raise RinProtocolError( + "invalid_job", + "Generation result " + field + " must be a non-negative integer", + ) + if "cache_hit" in result and not isinstance(result["cache_hit"], bool): + raise RinProtocolError( + "invalid_job", + "Generation result cache_hit must be a boolean", + ) + + def offline_proposal_result( request: Dict[str, Any], *, @@ -595,35 +1253,68 @@ def schedule( fallback_action_id: str = "", deadline_seconds: float = 25.0, poll_interval: float = 0.1, + known_job_id: str = "", + allow_offline_before_submit: bool = True, ) -> str: request_id = _path_identifier(str(request.get("request_id", ""))) + if known_job_id: + known_job_id = _path_identifier(known_job_id) + request_snapshot = _json_clone(request) request_fingerprint = hashlib.sha256(json.dumps( - request, + request_snapshot, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ).encode("utf-8")).hexdigest() + resumed = False with self._lock: if request_id in self._entries: - if self._entries[request_id]["request_fingerprint"] != request_fingerprint: + entry = self._entries[request_id] + if entry["request_fingerprint"] != request_fingerprint: raise RinProtocolError( "request_id_conflict", "Request id was already used with a different proposal payload", ) - return request_id - self._prune_locked() - if len(self._entries) >= self.maximum: - raise RinProtocolError("registry_full", "Rin background registry is full") - cancel_event = threading.Event() - self._entries[request_id] = { - "status": "pending", - "request_fingerprint": request_fingerprint, - "cancel_event": cancel_event, - "result": None, - "error_code": "", - } - - request_snapshot = _json_clone(request) + if entry["status"] != "unresolved": + return request_id + resumed = True + cancel_event = threading.Event() + entry["status"] = "pending" + entry["cancel_event"] = cancel_event + entry["result"] = None + entry["error_code"] = "" + request_snapshot = _json_clone(entry["request"]) + fallback_action_id = str(entry["fallback_action_id"]) + deadline_seconds = float(entry["deadline_seconds"]) + poll_interval = float(entry["poll_interval"]) + known_job_id = str(entry.get("job_id", "")) + allow_offline_before_submit = False + else: + self._prune_locked() + if len(self._entries) >= self.maximum: + raise RinProtocolError("registry_full", "Rin background registry is full") + cancel_event = threading.Event() + self._entries[request_id] = { + "status": "pending", + "request_fingerprint": request_fingerprint, + "request": request_snapshot, + "fallback_action_id": str(fallback_action_id), + "deadline_seconds": float(deadline_seconds), + "poll_interval": float(poll_interval), + "job_id": known_job_id, + "allow_offline_before_submit": bool(allow_offline_before_submit), + "cancel_event": cancel_event, + "result": None, + "error_code": "", + } + + def retain_job_id(job_id: str) -> bool: + with self._lock: + entry = self._entries.get(request_id) + if entry is None or entry["request_fingerprint"] != request_fingerprint: + return False + entry["job_id"] = _path_identifier(job_id) + return True def worker() -> None: try: @@ -633,25 +1324,41 @@ def worker() -> None: deadline_seconds=deadline_seconds, poll_interval=poll_interval, cancel_event=cancel_event, + known_job_id=known_job_id, + persist_job_id=retain_job_id, + allow_offline_before_submit=allow_offline_before_submit, ) status = "complete" error_code = "" + retained_job_id = str(result.get("job_id", "")) except RinError as exc: result = None - status = "canceled" if exc.code == "job_canceled" else "failed" + if exc.code in UNRESOLVED_PROPOSAL_CODES: + status = "unresolved" + else: + status = "canceled" if exc.code == "job_canceled" else "failed" error_code = exc.code + retained_job_id = getattr(exc, "job_id", "") or known_job_id with self._lock: entry = self._entries.get(request_id) if entry is not None: entry["status"] = status entry["result"] = _json_clone(result) if result is not None else None entry["error_code"] = error_code + if retained_job_id: + entry["job_id"] = _path_identifier(retained_job_id) try: launch(worker) except Exception: with self._lock: - self._entries.pop(request_id, None) + if resumed: + entry = self._entries.get(request_id) + if entry is not None: + entry["status"] = "unresolved" + entry["error_code"] = "worker_start_failed" + else: + self._entries.pop(request_id, None) raise RinTransportError("worker_start_failed", "Could not start Rin background worker") return request_id @@ -669,9 +1376,27 @@ def consume(self, request_id: str) -> Optional[Dict[str, Any]]: return { "status": entry["status"], "error_code": entry["error_code"], + "job_id": str(entry.get("job_id", "")), "result": _json_clone(entry["result"]) if entry["result"] is not None else None, } + def attempt(self, request_id: str) -> Optional[Dict[str, Any]]: + """Return a plain resumable record for a pending or unresolved attempt.""" + with self._lock: + entry = self._entries.get(str(request_id)) + if not entry or entry["status"] not in ("pending", "unresolved"): + return None + return { + "status": str(entry["status"]), + "request": _json_clone(entry["request"]), + "fallback_action_id": str(entry["fallback_action_id"]), + "job_id": str(entry.get("job_id", "")), + "error_code": str(entry.get("error_code", "")), + # Any game-persisted record is, by definition, a resumed + # attempt after reload and must never authorize offline work. + "allow_offline_before_submit": False, + } + def cancel(self, request_id: str) -> bool: with self._lock: entry = self._entries.get(str(request_id)) diff --git a/adapters/renpy/test_rin_client.py b/adapters/renpy/test_rin_client.py index 8da533f..c718597 100644 --- a/adapters/renpy/test_rin_client.py +++ b/adapters/renpy/test_rin_client.py @@ -1,5 +1,7 @@ +import errno import io import json +import socket import ast import textwrap import threading @@ -55,24 +57,16 @@ def open(self, request, timeout): if self.polls == 1: return _Response(200, { "ok": True, - "data": {"job_id": "job.fixture", "status": "running"}, + "data": _proposal_job("running"), }) return _Response(200, { "ok": True, - "data": { - "job_id": "job.fixture", - "status": "succeeded", - "proposal": { - "id": "proposal.fixture", - "action": {"id": "talk", "kind": "dialogue", "description": "Talk"}, - "policy_source": "deterministic", - }, - }, + "data": _proposal_job("succeeded"), }) if request.get_method() == "DELETE" and path == "/v1/jobs/job.fixture": return _Response(200, { "ok": True, - "data": {"job_id": "job.fixture", "status": "canceled"}, + "data": _proposal_job("canceled"), }) if request.get_method() == "POST" and path == "/v1/generation/jobs": return _Response(202, { @@ -84,12 +78,17 @@ def open(self, request, timeout): if self.generation_polls == 1: return _Response(200, { "ok": True, - "data": {"job_id": "gen.fixture", "status": "running"}, + "data": { + "job_id": "gen.fixture", + "request_id": "generation.fixture", + "status": "running", + }, }) return _Response(200, { "ok": True, "data": { "job_id": "gen.fixture", + "request_id": "generation.fixture", "status": "succeeded", "result": { "content": '{"narration":"雨停了。"}', @@ -100,7 +99,11 @@ def open(self, request, timeout): if request.get_method() == "DELETE" and path == "/v1/generation/jobs/gen.fixture": return _Response(200, { "ok": True, - "data": {"job_id": "gen.fixture", "status": "canceled"}, + "data": { + "job_id": "gen.fixture", + "request_id": "generation.fixture", + "status": "canceled", + }, }) if path == "/redirect": payload = json.dumps({"ok": False}).encode("utf-8") @@ -129,6 +132,43 @@ def _proposal_request(): } +def _valid_proposal(request=None, **changes): + request = request or _proposal_request() + proposal = { + "id": "proposal.fixture", + "session_id": request["session_id"], + "request_id": request["request_id"], + "actor_id": request["actor_id"], + "tick": request["tick"], + "action": json.loads(json.dumps(request["candidate_actions"][0])), + "policy_source": "deterministic", + } + proposal.update(changes) + return proposal + + +def _proposal_job( + status, + *, + request=None, + job_id="job.fixture", + proposal=None, + error=None, +): + request = request or _proposal_request() + job = { + "job_id": job_id, + "session_id": request["session_id"], + "request_id": request["request_id"], + "status": status, + } + if status == "succeeded": + job["proposal"] = proposal if proposal is not None else _valid_proposal(request) + if error is not None: + job["error"] = error + return job + + def _generation_request(): return { "protocol_version": rin_client.PROTOCOL_VERSION, @@ -148,6 +188,17 @@ def _client_with_opener(token=""): return client +class _AdvancingClock: + def __init__(self): + self.value = 0.0 + + def now(self): + return self.value + + def sleep(self, seconds): + self.value += seconds + + class RinClientTests(unittest.TestCase): def test_living_world_routes(self): client = _client_with_opener() @@ -176,12 +227,12 @@ def test_async_proposal_flow_and_token(self): self.assertEqual(client._opener.authorization, "Bearer fixture-token") self.assertEqual(client._opener.last_payload["request_id"], "request.fixture") - def test_transport_failure_uses_authored_fallback(self): + def test_definite_connection_refusal_uses_authored_fallback(self): client = rin_client.RinClient() class FailingOpener: def open(self, request, timeout): - raise URLError("dial failed with fixture-token") + raise URLError(ConnectionRefusedError(errno.ECONNREFUSED, "connection refused")) client._opener = FailingOpener() result = client.propose_with_fallback( @@ -190,11 +241,52 @@ def open(self, request, timeout): ) self.assertEqual(result["source"], "offline") self.assertFalse(result["committable"]) - self.assertEqual(result["fallback_reason"], "transport_failed") + self.assertEqual(result["fallback_reason"], "transport_unavailable") self.assertEqual(result["proposal"]["action"]["id"], "wait") self.assertEqual(result["proposal"]["policy_source"], "adapter-offline") self.assertNotIn("fixture-token", json.dumps(result)) + def test_ambiguous_submission_timeout_does_not_execute_fallback(self): + client = rin_client.RinClient() + + class TimingOutOpener: + def open(self, request, timeout): + raise URLError(socket.timeout("response was lost")) + + client._opener = TimingOutOpener() + with self.assertRaises(rin_client.RinJobError) as caught: + client.propose_with_fallback( + _proposal_request(), + fallback_action_id="wait", + ) + self.assertEqual(caught.exception.code, "proposal_outcome_unknown") + + def test_gateway_error_after_submission_does_not_execute_fallback(self): + client = rin_client.RinClient() + + class GatewayOpener: + def open(self, request, timeout): + payload = json.dumps({ + "ok": False, + "error": {"code": "gateway_timeout", "message": "Upstream response was lost"}, + }).encode("utf-8") + raise HTTPError( + request.full_url, + 504, + "Gateway Timeout", + {"Content-Length": str(len(payload))}, + io.BytesIO(payload), + ) + + client._opener = GatewayOpener() + with self.assertRaises(rin_client.RinJobError) as caught: + client.propose_with_fallback( + _proposal_request(), + fallback_action_id="wait", + ) + self.assertEqual(caught.exception.status, 504) + self.assertEqual(caught.exception.code, "proposal_outcome_unknown") + def test_structured_generation_flow(self): client = _client_with_opener("fixture-token") result = client.generate_json( @@ -255,6 +347,328 @@ def test_background_registry_rejects_request_id_conflict(self): registry.schedule(changed, lambda worker: None) self.assertEqual(caught.exception.code, "request_id_conflict") + def test_background_registry_retains_and_recovers_unresolved_attempts(self): + for unresolved_code in ( + "proposal_outcome_unknown", + "job_outcome_unknown", + "job_cancel_unconfirmed", + ): + with self.subTest(code=unresolved_code): + class RecoveringClient: + def __init__(self): + self.calls = [] + + def propose_with_fallback(self, request, **options): + self.calls.append({ + "request": json.loads(json.dumps(request)), + "known_job_id": options.get("known_job_id", ""), + }) + retain = options["persist_job_id"] + if len(self.calls) == 1: + self.assert_retained = retain("job.retained") + raise rin_client.RinJobError( + unresolved_code, + "Outcome remains unresolved", + job_id="job.retained", + ) + return { + "source": "sidecar", + "committable": True, + "fallback_reason": "", + "job_id": "job.retained", + "proposal": {"id": "proposal.recovered"}, + } + + recovering = RecoveringClient() + registry = rin_client.BackgroundProposalRegistry(recovering, maximum=1) + request = _proposal_request() + request_id = registry.schedule(request, lambda worker: worker()) + + self.assertTrue(recovering.assert_retained) + self.assertEqual(registry.status(request_id), "unresolved") + self.assertIsNone(registry.consume(request_id)) + attempt = registry.attempt(request_id) + self.assertEqual(attempt["request"], request) + self.assertEqual(attempt["job_id"], "job.retained") + self.assertEqual(attempt["error_code"], unresolved_code) + json.dumps(attempt) + + other = dict(request) + other["request_id"] = "request.other" + with self.assertRaises(rin_client.RinProtocolError) as full: + registry.schedule(other, lambda worker: worker()) + self.assertEqual(full.exception.code, "registry_full") + + registry.schedule(request, lambda worker: worker()) + self.assertEqual(registry.status(request_id), "complete") + self.assertEqual(recovering.calls[1]["known_job_id"], "job.retained") + consumed = registry.consume(request_id) + self.assertEqual(consumed["result"]["proposal"]["id"], "proposal.recovered") + self.assertEqual(consumed["job_id"], "job.retained") + + def test_empty_job_attempt_resume_never_uses_offline_fallback(self): + request = _proposal_request() + + # The game persists this pending record before the worker's first POST, + # then the process exits before the worker starts. + original = rin_client.BackgroundProposalRegistry(rin_client.RinClient()) + request_id = original.schedule(request, lambda _worker: None) + persisted = original.attempt(request_id) + self.assertEqual(persisted["job_id"], "") + self.assertFalse(persisted["allow_offline_before_submit"]) + + class RefusingOpener: + def open(self, _request, timeout): + raise URLError( + ConnectionRefusedError(errno.ECONNREFUSED, "connection refused") + ) + + restarted_client = rin_client.RinClient() + restarted_client._opener = RefusingOpener() + restarted = rin_client.BackgroundProposalRegistry(restarted_client) + restarted.schedule( + persisted["request"], + lambda worker: worker(), + fallback_action_id=persisted["fallback_action_id"], + known_job_id=persisted["job_id"], + allow_offline_before_submit=persisted["allow_offline_before_submit"], + ) + + self.assertEqual(restarted.status(request_id), "unresolved") + self.assertIsNone(restarted.consume(request_id)) + unresolved = restarted.attempt(request_id) + self.assertEqual(unresolved["request"], request) + self.assertEqual(unresolved["job_id"], "") + self.assertEqual(unresolved["error_code"], "proposal_outcome_unknown") + + # A later exact-request resume may recover normally, still with offline + # disabled because it is the same durable attempt. + restarted_client._opener = _Opener() + restarted.schedule(request, lambda worker: worker()) + self.assertEqual(restarted.status(request_id), "complete") + recovered = restarted.consume(request_id) + self.assertEqual(recovered["result"]["source"], "sidecar") + + def test_known_job_not_found_reposts_exact_request_once(self): + client = rin_client.RinClient() + request = _proposal_request() + gets = [] + submissions = [] + retained = [] + + def get_job(job_id): + gets.append(job_id) + if job_id == "job.previous": + raise rin_client.RinAPIError( + "job_not_found", + "Job expired", + status=404, + ) + return _proposal_job( + "succeeded", + request=request, + job_id="job.recovered", + proposal=_valid_proposal(request, id="proposal.recovered"), + ) + + def submit(payload): + submissions.append(json.loads(json.dumps(payload))) + return {"job_id": "job.recovered"} + + client.get_proposal_job = get_job + client.submit_proposal_job = submit + result = client.propose_with_fallback( + request, + known_job_id="job.previous", + persist_job_id=lambda job_id: retained.append(job_id) or True, + ) + + self.assertEqual(gets, ["job.previous", "job.recovered"]) + self.assertEqual(submissions, [request]) + self.assertEqual(retained, ["job.recovered"]) + self.assertEqual(result["proposal"]["id"], "proposal.recovered") + + def test_terminal_unknown_reposts_once_then_remains_unresolved(self): + client = rin_client.RinClient() + request = _proposal_request() + submissions = [] + gets = [] + + def get_job(job_id): + gets.append(job_id) + return _proposal_job( + "failed", + request=request, + job_id=job_id, + error={ + "code": "proposal_outcome_unknown", + "message": "Durability confirmation is still unknown", + }, + ) + + client.get_proposal_job = get_job + client.submit_proposal_job = lambda payload: ( + submissions.append(json.loads(json.dumps(payload))) + or {"job_id": "job.recovered"} + ) + + with self.assertRaises(rin_client.RinJobError) as caught: + client.propose_with_fallback( + request, + known_job_id="job.previous", + ) + + self.assertEqual(caught.exception.code, "proposal_outcome_unknown") + self.assertEqual(caught.exception.job_id, "job.recovered") + self.assertEqual(submissions, [request]) + self.assertEqual(gets, ["job.previous", "job.recovered"]) + + def test_proposal_job_identity_mismatches_fail_closed_for_every_status(self): + request = _proposal_request() + for status in ("queued", "running", "failed", "stale", "canceled", "succeeded"): + for field, wrong_value in ( + ("job_id", "job.crossed"), + ("session_id", "session.crossed"), + ("request_id", "request.crossed"), + ): + with self.subTest(status=status, field=field): + client = rin_client.RinClient() + job = _proposal_job( + status, + request=request, + error=( + {"code": "state_changed", "message": "Terminal"} + if status in ("failed", "stale", "canceled") + else None + ), + ) + job[field] = wrong_value + client.get_proposal_job = lambda _job_id, value=job: value + client.submit_proposal_job = lambda _request: self.fail( + "identity mismatch must not trigger a recovery POST" + ) + + with self.assertRaises(rin_client.RinJobError) as caught: + client.propose_with_fallback( + request, + known_job_id="job.fixture", + ) + + self.assertEqual(caught.exception.code, "job_outcome_unknown") + self.assertEqual(caught.exception.job_id, "job.fixture") + + def test_public_wait_without_request_validates_a_self_consistent_result(self): + client = rin_client.RinClient() + request = _proposal_request() + client.get_proposal_job = lambda _job_id: _proposal_job( + "succeeded", + request=request, + ) + job = client.wait_for_proposal("job.fixture") + self.assertEqual(job["proposal"]["id"], "proposal.fixture") + + crossed = _valid_proposal(request, request_id="request.other") + client.get_proposal_job = lambda _job_id: _proposal_job( + "succeeded", + request=request, + proposal=crossed, + ) + with self.assertRaises(rin_client.RinJobError) as caught: + client.wait_for_proposal("job.fixture") + self.assertEqual(caught.exception.code, "job_outcome_unknown") + + def test_successful_proposal_identity_and_numeric_mismatches_fail_closed(self): + request = _proposal_request() + cases = ( + ("empty_id", {"id": ""}), + ("wrong_session", {"session_id": "session.crossed"}), + ("wrong_request", {"request_id": "request.crossed"}), + ("wrong_actor", {"actor_id": "npc.crossed"}), + ("bool_tick", {"tick": True}), + ("float_tick", {"tick": 2.0}), + ("oversized_tick", {"tick": rin_client.MAX_INT64 + 1}), + ("negative_tick", {"tick": -1}), + ("missing_action", {"action": {}}), + ("non_candidate_action", { + "action": { + "id": "attack", + "kind": "combat", + "description": "Attack", + }, + }), + ("mutated_candidate_action", { + "action": { + "id": "talk", + "kind": "dialogue", + "description": "Different semantics", + }, + }), + ) + for name, changes in cases: + with self.subTest(case=name): + client = rin_client.RinClient() + job = _proposal_job( + "succeeded", + request=request, + proposal=_valid_proposal(request, **changes), + ) + client.get_proposal_job = lambda _job_id, value=job: value + client.submit_proposal_job = lambda _request: self.fail( + "malformed success must not trigger a recovery POST" + ) + + with self.assertRaises(rin_client.RinJobError) as caught: + client.propose_with_fallback( + request, + known_job_id="job.fixture", + ) + + self.assertEqual(caught.exception.code, "job_outcome_unknown") + self.assertEqual(caught.exception.job_id, "job.fixture") + + def test_registry_retains_crossed_job_as_unresolved(self): + request = _proposal_request() + client = rin_client.RinClient() + crossed = _proposal_job("running", request=request) + crossed["session_id"] = "session.crossed" + client.get_proposal_job = lambda _job_id: crossed + registry = rin_client.BackgroundProposalRegistry(client, maximum=1) + + request_id = registry.schedule( + request, + lambda worker: worker(), + known_job_id="job.fixture", + ) + + self.assertEqual(registry.status(request_id), "unresolved") + self.assertIsNone(registry.consume(request_id)) + attempt = registry.attempt(request_id) + self.assertEqual(attempt["request"], request) + self.assertEqual(attempt["job_id"], "job.fixture") + self.assertEqual(attempt["error_code"], "job_outcome_unknown") + + def test_delete_race_malformed_success_remains_unknown(self): + request = _proposal_request() + client = rin_client.RinClient() + canceled = threading.Event() + canceled.set() + client.submit_proposal_job = lambda _request: {"job_id": "job.fixture"} + client.cancel_proposal_job = lambda _job_id: _proposal_job( + "succeeded", + request=request, + proposal=_valid_proposal(request, tick=True), + ) + + with self.assertRaises(rin_client.RinJobError) as caught: + client.propose_with_fallback( + request, + fallback_action_id="wait", + cancel_event=canceled, + ) + + self.assertEqual(caught.exception.code, "job_outcome_unknown") + self.assertEqual(caught.exception.job_id, "job.fixture") + def test_invalid_fallback_is_rejected(self): with self.assertRaises(rin_client.RinProtocolError): rin_client.offline_proposal_result( @@ -273,6 +687,400 @@ def test_cancellation_reaches_job_endpoint(self): ) self.assertEqual(caught.exception.code, "job_canceled") + def test_explicit_cancellation_consumes_raced_success(self): + client = rin_client.RinClient() + request = _proposal_request() + canceled = threading.Event() + canceled.set() + client.cancel_proposal_job = lambda _job_id: _proposal_job( + "succeeded", + request=request, + proposal=_valid_proposal(request, id="proposal.cancel-race"), + ) + proposal_job = client.wait_for_proposal( + "job.fixture", + cancel_event=canceled, + expected_request=request, + ) + self.assertEqual(proposal_job["proposal"]["id"], "proposal.cancel-race") + + client.cancel_generation_job = lambda _job_id: { + "job_id": "gen.fixture", + "request_id": "generation.fixture", + "status": "succeeded", + "result": {"content": "finished before cancellation"}, + } + generation_job = client.wait_for_generation("gen.fixture", cancel_event=canceled) + self.assertEqual(generation_job["result"]["content"], "finished before cancellation") + + def test_explicit_cancellation_reports_unconfirmed_transport(self): + client = rin_client.RinClient() + canceled = threading.Event() + canceled.set() + + def fail_cancel(_job_id): + raise rin_client.RinTransportError("transport_failed", "Unavailable") + + client.cancel_proposal_job = fail_cancel + with self.assertRaises(rin_client.RinJobError) as caught: + client.wait_for_proposal("job.fixture", cancel_event=canceled) + self.assertEqual(caught.exception.code, "job_cancel_unconfirmed") + + def test_explicit_cancellation_never_turns_terminal_failure_into_fallback(self): + client = rin_client.RinClient() + request = _proposal_request() + canceled = threading.Event() + canceled.set() + client.submit_proposal_job = lambda _request: {"job_id": "job.fixture"} + client.cancel_proposal_job = lambda _job_id: _proposal_job( + "stale", + request=request, + error={"code": "state_changed", "message": "World changed"}, + ) + with self.assertRaises(rin_client.RinJobError) as caught: + client.propose_with_fallback( + request, + fallback_action_id="wait", + cancel_event=canceled, + ) + self.assertEqual(caught.exception.code, "state_changed") + + def test_terminal_unknown_outcome_from_poll_never_executes_fallback(self): + client = rin_client.RinClient() + request = _proposal_request() + client.submit_proposal_job = lambda _request: {"job_id": "job.fixture"} + client.get_proposal_job = lambda _job_id: _proposal_job( + "failed", + request=request, + error={ + "code": "proposal_outcome_unknown", + "message": "Provider outcome could not be established", + }, + ) + + with self.assertRaises(rin_client.RinJobError) as caught: + client.propose_with_fallback( + request, + fallback_action_id="wait", + ) + + self.assertEqual(caught.exception.code, "proposal_outcome_unknown") + + def test_terminal_unknown_outcome_from_delete_never_executes_fallback(self): + clock = _AdvancingClock() + client = rin_client.RinClient(clock=clock.now, sleeper=clock.sleep) + request = _proposal_request() + client.submit_proposal_job = lambda _request: {"job_id": "job.fixture"} + client.get_proposal_job = lambda _job_id: _proposal_job( + "running", + request=request, + ) + client.cancel_proposal_job = lambda _job_id: _proposal_job( + "failed", + request=request, + error={ + "code": "proposal_outcome_unknown", + "message": "Cancellation found an indeterminate provider outcome", + }, + ) + + with self.assertRaises(rin_client.RinJobError) as caught: + client.propose_with_fallback( + request, + fallback_action_id="wait", + deadline_seconds=0.05, + poll_interval=0.01, + ) + + self.assertEqual(caught.exception.code, "proposal_outcome_unknown") + + def test_failed_proposal_error_code_must_be_an_exact_protocol_id(self): + request = _proposal_request() + invalid_codes = (7, "", "job_canceled\x00", "x" * 97, " job_canceled ") + for route in ("poll", "cancel"): + for error_code in invalid_codes: + with self.subTest(route=route, error_code=repr(error_code)): + client = rin_client.RinClient() + canceled = threading.Event() + if route == "cancel": + canceled.set() + client.cancel_proposal_job = lambda _job_id, code=error_code: _proposal_job( + "failed", + request=request, + error={"code": code, "message": "Malformed terminal error"}, + ) + else: + client.get_proposal_job = lambda _job_id, code=error_code: _proposal_job( + "failed", + request=request, + error={"code": code, "message": "Malformed terminal error"}, + ) + + with self.assertRaises(rin_client.RinJobError) as caught: + client.propose_with_fallback( + request, + known_job_id="job.fixture", + fallback_action_id="wait", + cancel_event=canceled if route == "cancel" else None, + ) + + self.assertEqual(caught.exception.code, "job_outcome_unknown") + self.assertEqual(caught.exception.job_id, "job.fixture") + + def test_timeout_consumes_proposal_cancel_race_result(self): + clock = _AdvancingClock() + client = rin_client.RinClient(clock=clock.now, sleeper=clock.sleep) + request = _proposal_request() + client.get_proposal_job = lambda _job_id: _proposal_job( + "running", + request=request, + ) + client.cancel_proposal_job = lambda _job_id: _proposal_job( + "succeeded", + request=request, + proposal=_valid_proposal(request, id="proposal.race"), + ) + + job = client.wait_for_proposal( + "job.fixture", + deadline_seconds=0.05, + poll_interval=0.01, + expected_request=request, + ) + + self.assertEqual(job["proposal"]["id"], "proposal.race") + + def test_timeout_consumes_generation_cancel_race_result(self): + clock = _AdvancingClock() + client = rin_client.RinClient(clock=clock.now, sleeper=clock.sleep) + client.get_generation_job = lambda _job_id: { + "job_id": "gen.fixture", + "request_id": "generation.fixture", + "status": "queued", + } + client.cancel_generation_job = lambda _job_id: { + "job_id": "gen.fixture", + "request_id": "generation.fixture", + "status": "succeeded", + "result": {"content": "finished at the deadline"}, + } + + job = client.wait_for_generation( + "gen.fixture", + deadline_seconds=0.05, + poll_interval=0.01, + ) + + self.assertEqual(job["result"]["content"], "finished at the deadline") + + def test_generation_job_identity_is_bound_on_get_and_delete(self): + class CrossedOpener: + def open(self, _request, timeout): + return _Response(200, { + "ok": True, + "data": { + "job_id": "gen.crossed", + "request_id": "generation.fixture", + "status": "running", + }, + }) + + for method_name in ("get_generation_job", "cancel_generation_job"): + with self.subTest(method=method_name, direct=True): + client = rin_client.RinClient() + client._opener = CrossedOpener() + with self.assertRaises(rin_client.RinProtocolError) as caught: + getattr(client, method_name)("gen.fixture") + self.assertEqual(caught.exception.code, "invalid_job") + + for status in ("queued", "running", "failed", "succeeded"): + with self.subTest(method="GET", status=status): + client = rin_client.RinClient() + job = { + "job_id": "gen.crossed", + "request_id": "generation.fixture", + "status": status, + } + if status == "failed": + job["error"] = {"code": "generation_failed"} + if status == "succeeded": + job["result"] = {"content": "crossed"} + client.get_generation_job = lambda _job_id, value=job: value + with self.assertRaises(rin_client.RinProtocolError) as caught: + client.wait_for_generation("gen.fixture") + self.assertEqual(caught.exception.code, "invalid_job") + + client = rin_client.RinClient() + canceled = threading.Event() + canceled.set() + client.cancel_generation_job = lambda _job_id: { + "job_id": "gen.crossed", + "request_id": "generation.fixture", + "status": "succeeded", + "result": {"content": "crossed"}, + } + with self.assertRaises(rin_client.RinProtocolError) as caught: + client.wait_for_generation("gen.fixture", cancel_event=canceled) + self.assertEqual(caught.exception.code, "invalid_job") + + def test_generation_success_result_structure_is_strict(self): + invalid_results = ( + ("missing_content", {}), + ("non_string", {"content": 7}), + ("empty", {"content": ""}), + ("whitespace", {"content": " \t\r\n"}), + ("nul", {"content": "ok\x00"}), + ("invalid_utf8", {"content": "\ud800"}), + ( + "too_large", + {"content": "x" * (rin_client.MAX_GENERATION_CONTENT_BYTES + 1)}, + ), + ("model_type", {"content": "ok", "model": 7}), + ("prompt_tokens_bool", {"content": "ok", "prompt_tokens": True}), + ("negative_output_tokens", {"content": "ok", "output_tokens": -1}), + ("cache_hit_type", {"content": "ok", "cache_hit": "yes"}), + ) + for case, result in invalid_results: + with self.subTest(case=case): + client = rin_client.RinClient() + client.get_generation_job = lambda _job_id, value=result: { + "job_id": "gen.fixture", + "request_id": "generation.fixture", + "status": "succeeded", + "result": value, + } + with self.assertRaises(rin_client.RinProtocolError) as caught: + client.wait_for_generation("gen.fixture") + self.assertEqual(caught.exception.code, "invalid_job") + + class MalformedSuccessOpener: + def open(self, _request, timeout): + return _Response(200, { + "ok": True, + "data": { + "job_id": "gen.fixture", + "request_id": "generation.fixture", + "status": "succeeded", + "result": {"content": 7}, + }, + }) + + client = rin_client.RinClient() + client._opener = MalformedSuccessOpener() + with self.assertRaises(rin_client.RinProtocolError) as caught: + client.get_generation_job("gen.fixture") + self.assertEqual(caught.exception.code, "invalid_job") + + def test_generation_job_request_id_is_a_protocol_identifier(self): + invalid_request_ids = (None, 7, "", "generation\x00fixture", "x" * 97, " bad ") + for request_id in invalid_request_ids: + with self.subTest(request_id=repr(request_id)): + client = rin_client.RinClient() + client.get_generation_job = lambda _job_id, value=request_id: { + "job_id": "gen.fixture", + "request_id": value, + "status": "running", + } + with self.assertRaises(rin_client.RinProtocolError) as caught: + client.wait_for_generation("gen.fixture") + self.assertEqual(caught.exception.code, "invalid_job") + + def test_generate_json_rejects_non_finite_constants(self): + for content in ('{"value":NaN}', '{"value":Infinity}', '{"value":-Infinity}'): + with self.subTest(content=content): + client = rin_client.RinClient() + client.submit_generation_job = lambda _request: {"job_id": "gen.fixture"} + client.wait_for_generation = lambda *_args, value=content, **_kwargs: { + "job_id": "gen.fixture", + "request_id": "generation.fixture", + "status": "succeeded", + "result": {"content": value}, + } + with self.assertRaises(rin_client.RinProtocolError) as caught: + client.generate_json(_generation_request()) + self.assertEqual(caught.exception.code, "invalid_generation_json") + + def test_timeout_uses_cancel_terminal_state_and_validates_raced_success(self): + clock = _AdvancingClock() + client = rin_client.RinClient(clock=clock.now, sleeper=clock.sleep) + request = _proposal_request() + client.get_proposal_job = lambda _job_id: _proposal_job( + "running", + request=request, + ) + client.cancel_proposal_job = lambda _job_id: _proposal_job( + "canceled", + request=request, + error={"code": "job_canceled", "message": "Canceled"}, + ) + with self.assertRaises(rin_client.RinJobError) as caught: + client.wait_for_proposal( + "job.fixture", + deadline_seconds=0.05, + poll_interval=0.01, + expected_request=request, + ) + self.assertEqual(caught.exception.code, "job_canceled") + + clock.value = 0.0 + client.cancel_proposal_job = lambda _job_id: _proposal_job( + "succeeded", + request=request, + proposal={}, + ) + with self.assertRaises(rin_client.RinJobError) as caught: + client.wait_for_proposal( + "job.fixture", + deadline_seconds=0.05, + poll_interval=0.01, + expected_request=request, + ) + self.assertEqual(caught.exception.code, "job_outcome_unknown") + + def test_timeout_follows_cancel_api_rin_error(self): + clock = _AdvancingClock() + client = rin_client.RinClient(clock=clock.now, sleeper=clock.sleep) + client.get_generation_job = lambda _job_id: { + "job_id": "gen.fixture", + "request_id": "generation.fixture", + "status": "running", + } + + def fail_cancel(_job_id): + raise rin_client.RinTransportError("transport_failed", "Unavailable") + + client.cancel_generation_job = fail_cancel + with self.assertRaises(rin_client.RinJobError) as caught: + client.wait_for_generation( + "gen.fixture", + deadline_seconds=0.05, + poll_interval=0.01, + ) + self.assertEqual(caught.exception.code, "job_timeout") + + def test_unconfirmed_timeout_does_not_execute_fallback(self): + clock = _AdvancingClock() + client = rin_client.RinClient(clock=clock.now, sleeper=clock.sleep) + request = _proposal_request() + client.submit_proposal_job = lambda _request: {"job_id": "job.fixture"} + client.get_proposal_job = lambda _job_id: _proposal_job( + "running", + request=request, + ) + + def fail_cancel(_job_id): + raise rin_client.RinTransportError("transport_failed", "response lost") + + client.cancel_proposal_job = fail_cancel + with self.assertRaises(rin_client.RinJobError) as caught: + client.propose_with_fallback( + request, + fallback_action_id="wait", + deadline_seconds=0.05, + poll_interval=0.01, + ) + self.assertEqual(caught.exception.code, "job_outcome_unknown") + def test_response_size_limit_is_enforced(self): client = rin_client.RinClient(max_response_bytes=1024) @@ -292,6 +1100,10 @@ def test_renpy_bridge_python_block_parses(self): python_source = textwrap.dedent(source.split(marker, 1)[1]) ast.parse(python_source) self.assertNotIn("default _RIN_", source) + self.assertIn("def rin_proposal_attempt(", source) + self.assertIn("def rin_resume_proposal(", source) + self.assertIn("resuming=True", source) + self.assertIn("allow_offline_before_submit=False", source) if __name__ == "__main__": diff --git a/compat/adapter_examples_test.go b/compat/adapter_examples_test.go index 167cf95..0d06419 100644 --- a/compat/adapter_examples_test.go +++ b/compat/adapter_examples_test.go @@ -21,11 +21,18 @@ func TestEngineExamplesPreserveAsyncAuthorityBoundary(t *testing.T) { "request.max_redirects = 0", "request.body_size_limit = max_response_bytes", "HTTPClient.METHOD_DELETE", + `_closed_result("proposal_outcome_unknown")`, + "_cancel_and_resolve", "\"committable\": false", "\"policy_source\": \"adapter-offline\"", "/v1/session/activity", "/v1/world/arbitrate", "/v1/session/timeline", + "AMBIGUOUS_PROPOSAL_ERRORS", + "_terminal_error_code", + "_same_protocol_id", + "_is_valid_action_spec", + "left_number >= 0.0", }, forbidden: []string{"OS.execute", "FileAccess.open", "Thread.wait_to_finish"}, }, @@ -37,13 +44,36 @@ func TestEngineExamplesPreserveAsyncAuthorityBoundary(t *testing.T) { "request.redirectLimit = 0", "CappedDownloadHandler", "WaitForSecondsRealtime", + `BuildClosedResult("proposal_outcome_unknown"`, + "ResolveCancellation", + "allowOfflineBeforeSubmit", + "if (!IsConfigured)", "committable = false", "policy_source = \"adapter-offline\"", "/v1/session/activity", "/v1/world/arbitrate", "/v1/session/timeline", + "public long observed_tick", + "public long updated_tick", + "public long progress_accumulator", + "public bool status_explicit", + "public long status_updated_tick", + "public string status_source_event_id", + "public string outcome_event_id", + "public long outcome_tick", + "bool allowOfflineBeforeSubmit = true", + "AmbiguousProposalErrors", + "TryGetTerminalErrorCode", + "TryReadTopLevelProtocolIdProperty", + "ActionMatchesCandidate", + }, + forbidden: []string{ + "Thread.Sleep", + ".Wait()", + "Process.Start", + "!IsConfigured || (allowOfflineBeforeSubmit && string.IsNullOrEmpty(jobId))", + "bool allowOfflineBeforeSubmit = false", }, - forbidden: []string{"Thread.Sleep", ".Wait()", "Process.Start"}, }, { name: "renpy", @@ -56,6 +86,8 @@ func TestEngineExamplesPreserveAsyncAuthorityBoundary(t *testing.T) { "/v1/session/activity", "/v1/world/arbitrate", "/v1/session/timeline", + "allow_offline_before_submit", + "_validate_generation_job_identity", }, forbidden: []string{"import requests", "subprocess", "os.system"}, }, @@ -80,3 +112,985 @@ func TestEngineExamplesPreserveAsyncAuthorityBoundary(t *testing.T) { }) } } + +func TestEngineAdaptersFailClosedForUnknownProposalOutcomes(t *testing.T) { + tests := []struct { + name string + path string + required []string + minimumOccurrences map[string]int + }{ + { + name: "godot", + path: "../examples/godot/rin_client.gd", + required: []string{ + `and not submission.has("status")`, + `if reason == "proposal_outcome_unknown" and not recovery_post_used:`, + "if reason in AMBIGUOUS_PROPOSAL_ERRORS:", + "return _closed_result(reason, job_id)", + `"status": status`, + }, + minimumOccurrences: map[string]int{"_terminal_job_result(": 3}, + }, + { + name: "unity", + path: "../examples/unity/RinClient.cs", + required: []string{ + `if (reason == "proposal_outcome_unknown" && !recoveryPostUsed)`, + "if (AmbiguousProposalErrors.Contains(reason))", + "return BuildClosedResult(reason, jobId);", + }, + minimumOccurrences: map[string]int{"BuildTerminalResult(": 3}, + }, + { + name: "renpy", + path: "../adapters/renpy/rin_client.py", + required: []string{ + `"proposal_outcome_unknown",`, + "allow_offline_before_submit=False", + "_validate_generation_job_identity(job, expected_job_id)", + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + payload, err := os.ReadFile(test.path) + if err != nil { + t.Fatal(err) + } + text := string(payload) + for _, required := range test.required { + if !strings.Contains(text, required) { + t.Fatalf("%s is missing fail-closed contract %q", test.path, required) + } + } + for fragment, minimum := range test.minimumOccurrences { + if count := strings.Count(text, fragment); count < minimum { + t.Fatalf( + "%s contains %q %d times; want at least %d", + test.path, + fragment, + count, + minimum, + ) + } + } + }) + } +} + +func TestEngineNpcExamplesPersistAuthoritativeReportsAtomically(t *testing.T) { + tests := []struct { + path string + required []string + forbidden []string + }{ + { + path: "../examples/godot/example_npc.gd", + required: []string{ + "_applied_operations", "_report_outbox", + `"features": ["outcome-reporting-v1"]`, + "_flush_report_outbox", "_persist_authoritative_transaction", + "_persist_report_acknowledgement", + `"request_id": "commit." + operation_id`, + `"kind": "observe"`, + `"request_id": "reconcile." + operation_id`, + `"event_id": "fallback." + operation_id`, + }, + forbidden: []string{"_outcome_outbox", "_flush_outcome_outbox", "_persist_operation_state"}, + }, + { + path: "../examples/unity/RinNpcExample.cs", + required: []string{ + "appliedOperations", "reportOutbox", + `features = new[] { "outcome-reporting-v1" }`, + "FlushReportOutbox", "PersistAuthoritativeTransaction", + "PersistReportAcknowledgement", + `request_id = "commit." + operationId`, + "PendingReport.Observe", + `request_id = "reconcile." + operationId`, + `event_id = "fallback." + operationId`, + }, + forbidden: []string{"outcomeOutbox", "FlushOutcomeOutbox", "PersistOperationState"}, + }, + } + for _, test := range tests { + payload, err := os.ReadFile(test.path) + if err != nil { + t.Fatal(err) + } + for _, fragment := range test.required { + if !strings.Contains(string(payload), fragment) { + t.Errorf("%s is missing authoritative-report contract %q", test.path, fragment) + } + } + for _, fragment := range test.forbidden { + if strings.Contains(string(payload), fragment) { + t.Errorf("%s contains obsolete split-persistence pattern %q", test.path, fragment) + } + } + } +} + +func TestEngineNpcExamplesResumeDurableProposalAttempts(t *testing.T) { + tests := []struct { + name string + clientPath string + gamePath string + clientRequired []string + gameRequired []string + gameForbidden []string + persistMarker string + submitMarker string + }{ + { + name: "godot", + clientPath: "../examples/godot/rin_client.gd", + gamePath: "../examples/godot/example_npc.gd", + clientRequired: []string{ + "known_job_id: String", + "persist_job_id.call(job_id)", + `== "job_not_found"`, + `reason == "proposal_outcome_unknown" and not recovery_post_used`, + "recovery_post_used = true", + }, + gameRequired: []string{ + "_proposal_attempts", + `"request": stable_request.duplicate(true)`, + `"sequence": next_sequence`, + `"job_id": ""`, + "if resuming_attempt:", + "_operation_sequence = maxi(", + "_persist_proposal_job_id", + "not resuming_attempt", + "_proposal_attempts.erase(session_id)", + "_proposal_attempts[session_id] = proposal_attempt", + "Engine.get_physics_frames()", + }, + gameForbidden: []string{ + "_operation_sequence += 1", + "var _authoritative_tick :=", + }, + persistMarker: "_persist_new_proposal_attempt(", + submitMarker: "await rin.propose_with_fallback(", + }, + { + name: "unity", + clientPath: "../examples/unity/RinClient.cs", + gamePath: "../examples/unity/RinNpcExample.cs", + clientRequired: []string{ + "string knownJobId", + "persistJobId(jobId)", + `pollCall.ErrorCode == "job_not_found"`, + `reason == "proposal_outcome_unknown" && !recoveryPostUsed`, + "recoveryPostUsed = true", + }, + gameRequired: []string{ + "proposalAttempts", + "new ProposalAttempt(", + "nextSequence", + "if (!resuming)", + "operationSequence = Math.Max(", + `knownJobId: attempt.jobId`, + "PersistProposalJobId", + "allowOfflineBeforeSubmit: !resuming", + "proposalAttempts.Remove(sessionId)", + "proposalAttempts[sessionId] = proposalAttempt", + "Time.frameCount", + }, + gameForbidden: []string{ + "operationSequence++", + "authoritativeGameTick", + }, + persistMarker: "PersistNewProposalAttempt(", + submitMarker: "yield return rin.ProposeWithFallback(", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + clientPayload, err := os.ReadFile(test.clientPath) + if err != nil { + t.Fatal(err) + } + clientText := string(clientPayload) + for _, fragment := range test.clientRequired { + if !strings.Contains(clientText, fragment) { + t.Errorf("%s is missing resumable-client contract %q", test.clientPath, fragment) + } + } + + gamePayload, err := os.ReadFile(test.gamePath) + if err != nil { + t.Fatal(err) + } + gameText := string(gamePayload) + for _, fragment := range test.gameRequired { + if !strings.Contains(gameText, fragment) { + t.Errorf("%s is missing durable-attempt contract %q", test.gamePath, fragment) + } + } + for _, fragment := range test.gameForbidden { + if strings.Contains(gameText, fragment) { + t.Errorf("%s regrows sequence during a resumed attempt via %q", test.gamePath, fragment) + } + } + persistAt := strings.Index(gameText, test.persistMarker) + submitAt := strings.Index(gameText, test.submitMarker) + if persistAt < 0 || submitAt < 0 || persistAt >= submitAt { + t.Errorf( + "%s must persist a complete Proposal attempt before submitting it", + test.gamePath, + ) + } + }) + } +} + +func TestEngineNpcExamplesGateStartupOnAuthoritativeStateRecovery(t *testing.T) { + tests := []struct { + name string + path string + required []string + restoreCall string + firstOnlineOperation string + initializeStart string + persistInitialization string + publishInitialization string + }{ + { + name: "godot", + path: "../examples/godot/example_npc.gd", + required: []string{ + "_authoritative_state_ready = _restore_authoritative_state()", + "if not _authoritative_state_ready:", + `if status == "loaded":`, + `if status != "not_found":`, + `return {"status": "error", "error": "restore hook not configured"}`, + `"schema_version": 2`, + `"run_id": new_run_id`, + `"operation_sequence": 0`, + `"create_request": _build_create_request(new_run_id)`, + `"proposal_attempts": {}`, + `"applied_operations": {}`, + `"report_outbox": {}`, + "_persist_authoritative_state_initialization(initialized_state)", + "_proposal_attempts = restored_attempts.duplicate(true)", + "_applied_operations = restored_applied.duplicate(true)", + "_report_outbox = restored_outbox.duplicate(true)", + "or restored_applied.has(attempt_operation_id)", + "or restored_outbox.has(attempt_operation_id)", + "or not restored_applied.has(operation_key)", + `!= "propose." + attempt_operation_id`, + `!= "commit." + operation_key`, + `!= "outcome." + operation_key`, + `not _is_valid_protocol_id(proposal_id)`, + `!= "reconcile." + operation_key`, + `!= str(request.get("event_id", ""))`, + `!= request_tick`, + `"fallback." + operation_key`, + "var request_tick := _read_nonnegative_protocol_tick(", + "var proposal_tick := _read_nonnegative_protocol_tick(", + "maxi(request_tick, proposal_tick)", + "number > 9007199254740991.0", + }, + restoreCall: "_restore_authoritative_state()", + firstOnlineOperation: "await rin.create_session(", + initializeStart: "var new_run_id :=", + persistInitialization: "_persist_authoritative_state_initialization(initialized_state)", + publishInitialization: "return _hydrate_authoritative_state(initialized_state)", + }, + { + name: "unity", + path: "../examples/unity/RinNpcExample.cs", + required: []string{ + "authoritativeStateReady = RestoreAuthoritativeState();", + "if (!authoritativeStateReady)", + "AuthoritativeStateLoadStatus.Loaded", + "AuthoritativeStateLoadStatus.NotFound", + `AuthoritativeStateLoadResult.Failed("restore hook not configured")`, + "schemaVersion = 2", + "runId = newRunId", + "operationSequence = 0", + "createRequest = BuildCreateRequest(newRunId)", + "proposalAttempts = new ProposalAttemptState[0]", + "appliedOperations = new AppliedOperationState[0]", + "reportOutbox = new PendingReportState[0]", + "PersistAuthoritativeStateInitialization(initialized)", + "foreach (var entry in restoredAttempts) proposalAttempts.Add(", + "foreach (var entry in restoredApplied) appliedOperations.Add(", + "foreach (var entry in restoredOutbox) reportOutbox.Add(", + "!restoredApplied.ContainsKey(saved.operationId)", + "restoredApplied.ContainsKey(attempt.operationId)", + "restoredOutbox.ContainsKey(attempt.operationId)", + `!= "propose." + saved.operationId`, + `!= "commit." + saved.operationId`, + `!= "outcome." + saved.operationId`, + "!RinClient.IsProtocolId(saved.commit.proposal_id)", + `!= "reconcile." + saved.operationId`, + "saved.fallback.session_id != saved.commit.session_id", + "saved.fallback.event_id != saved.commit.event_id", + "saved.fallback.tick != saved.commit.tick", + `saved.observe.event_id != "fallback." + saved.operationId`, + "Math.Max(proposalAttempt.request.tick, proposalTick)", + "Math.Max(0L, (long)Time.frameCount)", + }, + restoreCall: "RestoreAuthoritativeState()", + firstOnlineOperation: "yield return rin.CreateSession(", + initializeStart: "var newRunId =", + persistInitialization: "PersistAuthoritativeStateInitialization(initialized)", + publishInitialization: "return TryHydrateAuthoritativeState(initialized)", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + payload, err := os.ReadFile(test.path) + if err != nil { + t.Fatal(err) + } + text := string(payload) + for _, fragment := range test.required { + if !strings.Contains(text, fragment) { + t.Errorf("%s is missing recovery contract %q", test.path, fragment) + } + } + restoreAt := strings.Index(text, test.restoreCall) + onlineAt := strings.Index(text, test.firstOnlineOperation) + if restoreAt < 0 || onlineAt < 0 || restoreAt >= onlineAt { + t.Errorf("%s must restore authoritative state before online work", test.path) + } + initializeAt := strings.Index(text, test.initializeStart) + persistAt := strings.Index(text, test.persistInitialization) + publishAt := strings.Index(text, test.publishInitialization) + if initializeAt < 0 || persistAt <= initializeAt || publishAt <= persistAt { + t.Errorf( + "%s must persist a confirmed-new identity before publishing it", + test.path, + ) + } + }) + } +} + +func TestEngineNpcExamplesRestoreClockIdentityAndFreshnessInvariants(t *testing.T) { + tests := []struct { + name string + path string + required []string + forbidden []string + persistCall string + sequencePublish string + tickPublish string + submitCall string + }{ + { + name: "godot", + path: "../examples/godot/example_npc.gd", + required: []string{ + "const MAX_PROTOCOL_INTEGER := 9223372036854775807", + `"last_authoritative_tick": 0`, + "state.get(\"last_authoritative_tick\")", + "attempt_tick > restored_last_tick", + "request_tick > restored_last_tick", + "_last_authoritative_tick = restored_last_tick", + "var expected_create := _build_create_request(restored_run_id)", + "_semantic_values_equal(restored_create, expected_create)", + "func _build_propose_request(", + "_semantic_values_equal(request, expected_request)", + `str(attempt.get("fallback_action_id", "")) != "wait"`, + "func _semantic_values_equal(", + "left.size() != right.size()", + "_allocate_fresh_proposal_tick()", + "_last_authoritative_tick >= MAX_PROTOCOL_INTEGER", + "_last_authoritative_tick + 1", + "authoritative_tick <= _last_authoritative_tick", + `_read_nonnegative_protocol_tick(request.get("tick")) != authoritative_tick`, + "_operation_sequence_from_id(", + "attempt_sequence != restored_sequence", + "canonical_sequence != attempt_sequence", + "applied_sequence > restored_sequence", + "operation_sequence > restored_sequence", + "request.get(\"accepted\") != applied.get(\"accepted\")", + "request.get(\"outcome\") != applied.get(\"outcome\")", + `str(fallback.get("source", "")) != "godot-example"`, + `!= "Authoritative outcome: " + str(applied.get("outcome"))`, + `!= "Local fallback %s: %s" % [`, + "var previous_last_tick := _last_authoritative_tick", + "_last_authoritative_tick = occurrence_tick", + "_last_authoritative_tick = previous_last_tick", + "_commit_authoritative_game_transaction(operation_id, occurrence_tick)", + `str(retained.get("session_id", "")) != str(proposal.get("session_id", ""))`, + `str(retained.get("id", "")) != proposal_id`, + `str(retained.get("request_id", "")) != str(proposal.get("request_id", ""))`, + `str(retained.get("actor_id", "")) != str(proposal.get("actor_id", ""))`, + "response_tick != retained_tick", + `str(retained_action.get("id", "")) != str(response_action.get("id", ""))`, + `str(retained_action.get("kind", "")) != str(response_action.get("kind", ""))`, + "response_revision_base != retained_revision_base", + "retained_head_hash != response_head_hash", + "response_created != retained_created", + "response_world_base != retained_world_base", + "_semantic_values_equal(retained_action, response_action)", + "_semantic_values_equal(stable_action, response_action)", + "== retained_world_base", + "== retained_created", + "func _apply_planned_game_effect(", + "if not _authoritative_state_ready:", + }, + forbidden: []string{ + "func apply_planned_game_effect(", + "== int(proposal.get(\"created_revision\"", + }, + persistCall: "if not _persist_new_proposal_attempt(", + sequencePublish: "_operation_sequence = next_sequence", + tickPublish: "_last_authoritative_tick = new_game_tick", + submitCall: "await rin.propose_with_fallback(", + }, + { + name: "unity", + path: "../examples/unity/RinNpcExample.cs", + required: []string{ + "private long lastAuthoritativeTick;", + "lastAuthoritativeTick = 0", + "state.lastAuthoritativeTick < 0", + "saved.request.tick > state.lastAuthoritativeTick", + "saved.commit.tick > state.lastAuthoritativeTick", + "saved.observe.tick > state.lastAuthoritativeTick", + "lastAuthoritativeTick = state.lastAuthoritativeTick", + "var expectedCreateRequest = BuildCreateRequest(state.runId)", + "SemanticDtoEquals(state.createRequest, expectedCreateRequest)", + "BuildProposeRequest(", + "SemanticDtoEquals(", + `saved.fallbackActionId != "wait"`, + "type.GetFields(BindingFlags.Instance | BindingFlags.Public)", + "TryAllocateFreshProposalTick(out var newGameTick)", + "lastAuthoritativeTick == long.MaxValue", + "lastAuthoritativeTick + 1", + "authoritativeTick <= lastAuthoritativeTick", + "attempt.request.tick != authoritativeTick", + "TryParseOperationSequence(", + "saved.sequence != state.operationSequence", + "attemptOperationSequence != saved.sequence", + "appliedOperationSequence > state.operationSequence", + "outboxOperationSequence > state.operationSequence", + "saved.commit.accepted != restoredApplied[saved.operationId].accepted", + "saved.commit.outcome != restoredApplied[saved.operationId].outcome", + "OutcomeObserveMatchesApplied(", + `observe.source != "unity-example"`, + `observe.summary == "Authoritative outcome: " + applied.outcome`, + `"Local fallback " + applied.actionId + ": " + applied.outcome`, + "var previousLastTick = lastAuthoritativeTick", + "lastAuthoritativeTick = occurrenceTick", + "lastAuthoritativeTick = previousLastTick", + "CommitAuthoritativeGameTransaction(operationId, occurrenceTick)", + "retained.session_id != proposal.session_id", + "string.IsNullOrEmpty(retained.id)", + "retained.request_id != proposal.request_id", + "retained.actor_id != proposal.actor_id", + "retained.tick != proposal.tick", + "retained.action.id != proposal.action.id", + "retained.action.kind != proposal.action.kind", + "retained.based_on_revision != proposal.based_on_revision", + "retained.based_on_head_hash != proposal.based_on_head_hash", + "retained.based_on_world_revision != proposal.based_on_world_revision", + "retained.created_revision != proposal.created_revision", + "SemanticDtoEquals(retained.action, proposal.action)", + "SemanticDtoEquals(stableAction, proposal.action)", + "retained.has_unsupported_action_parameters", + "proposal.has_unsupported_action_parameters", + "state.world_revision == retained.based_on_world_revision", + "state.revision == retained.created_revision", + }, + forbidden: []string{ + "state.world_revision == proposal.based_on_world_revision", + "state.revision == proposal.created_revision", + }, + persistCall: "if (!PersistNewProposalAttempt(", + sequencePublish: "operationSequence = nextSequence", + tickPublish: "lastAuthoritativeTick = newGameTick", + submitCall: "yield return rin.ProposeWithFallback(", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + payload, err := os.ReadFile(test.path) + if err != nil { + t.Fatal(err) + } + text := string(payload) + for _, fragment := range test.required { + if !strings.Contains(text, fragment) { + t.Errorf("%s is missing restored-state invariant %q", test.path, fragment) + } + } + for _, fragment := range test.forbidden { + if strings.Contains(text, fragment) { + t.Errorf("%s still trusts an unsafe or obsolete path %q", test.path, fragment) + } + } + persistAt := strings.Index(text, test.persistCall) + sequenceAt := strings.Index(text, test.sequencePublish) + tickAt := strings.Index(text, test.tickPublish) + submitAt := strings.Index(text, test.submitCall) + if persistAt < 0 || + sequenceAt <= persistAt || + tickAt <= persistAt || + submitAt <= sequenceAt || + submitAt <= tickAt { + t.Errorf( + "%s must durably allocate sequence/tick before publishing them or submitting", + test.path, + ) + } + }) + } +} + +func TestUnityExampleRejectsUnrepresentableActionParameters(t *testing.T) { + const path = "../examples/unity/RinClient.cs" + payload, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(payload) + for _, fragment := range []string{ + "ActionHasUnsupportedParameters(proposalJson)", + `FindTopLevelPropertyValue(actionJson, "parameters") >= 0`, + "[NonSerialized] public bool has_unsupported_action_parameters", + "public string description;", + "public string[] target_ids;", + } { + if !strings.Contains(text, fragment) { + t.Errorf("%s is missing complete-action protection %q", path, fragment) + } + } + if count := strings.Count( + text, + "proposal.has_unsupported_action_parameters =", + ); count < 2 { + t.Errorf( + "%s marks unsupported parameters in only %d Proposal decode paths; want 2", + path, + count, + ) + } +} + +func TestEngineExamplesValidateCanonicalRecoveryJobsAndSchedulerHeadroom(t *testing.T) { + tests := []struct { + name string + path string + required []string + forbidden []string + ordered []string + }{ + { + name: "godot-game", + path: "../examples/godot/example_npc.gd", + required: []string{ + "const NPC_THINK_EVERY_TICKS := 5", + "_build_commit_report_entry(", + "_build_outcome_observe_request(", + "_build_fallback_observe_request(", + `"observer_ids": ["npc.mira"]`, + "not _semantic_values_equal(entry, expected_entry)", + "not _semantic_values_equal(applied, {", + "not _semantic_values_equal(attempt, expected_attempt)", + "and not _is_valid_protocol_id(attempt_job_id)", + "occurrence_tick > MAX_PROTOCOL_INTEGER - NPC_THINK_EVERY_TICKS", + `effective_planned["accepted"] = false`, + "_apply_planned_game_effect(effective_planned)", + }, + forbidden: []string{ + `str(attempt.get("job_id", ""))`, + `"observer_ids": [proposal["actor_id"]]`, + }, + ordered: []string{ + "var occurrence_tick := maxi(", + "occurrence_tick > MAX_PROTOCOL_INTEGER - NPC_THINK_EVERY_TICKS", + "_apply_planned_game_effect(effective_planned)", + }, + }, + { + name: "godot-client", + path: "../examples/godot/rin_client.gd", + required: []string{ + "not known_job_id.is_empty() and not _is_valid_protocol_id(known_job_id)", + "var job_id_value = submission_data.get(\"job_id\")", + "if not _is_valid_protocol_id(job_id_value):", + "if not _is_valid_protocol_id(job_id):", + "func _job_shape_matches_status(", + `var has_proposal := job.has("proposal")`, + `var has_error := job.has("error")`, + `if status == "succeeded":`, + `if status in ["failed", "stale", "canceled"]:`, + `if status == "queued" or status == "running":`, + `return _closed_result("invalid_job", job_id)`, + }, + forbidden: []string{ + `str(submission["job_id"])`, + `str(submission.get("data", {}).get("job_id", ""))`, + }, + }, + { + name: "unity-game", + path: "../examples/unity/RinNpcExample.cs", + required: []string{ + "private const long NpcThinkEveryTicks = 5;", + "BuildCommitRequest(", + "BuildOutcomeObserveRequest(", + "BuildFallbackObserveRequest(", + `observer_ids = new[] { "npc.mira" }`, + "saved.commit,\n BuildCommitRequest(", + "SemanticDtoEquals(saved, new ProposalAttemptState", + "RinClient.IsProtocolId(saved.jobId)", + "occurrenceTick > long.MaxValue - NpcThinkEveryTicks", + "ApplyPlannedGameEffect(effectivePlanned, transaction)", + "WithAppliedOutcome(effectivePlanned)", + }, + ordered: []string{ + "var occurrenceTick = Math.Max(", + "occurrenceTick > long.MaxValue - NpcThinkEveryTicks", + "ApplyPlannedGameEffect(effectivePlanned, transaction)", + }, + }, + { + name: "unity-client", + path: "../examples/unity/RinClient.cs", + required: []string{ + "jobId.Length > 0 && !IsValidProtocolId(jobId)", + "TryReadTopLevelProtocolIdProperty(", + `submissionJson,`, + `"job_id",`, + "JobShapeMatchesStatus(job, pollCall.Text)", + "JobShapeMatchesStatus(job, call.Text)", + `var proposalStart = FindTopLevelPropertyValue(jobJson, "proposal")`, + `var errorStart = FindTopLevelPropertyValue(jobJson, "error")`, + `job.status == "queued" || job.status == "running"`, + "public static bool IsProtocolId(string value)", + }, + forbidden: []string{ + "if (string.IsNullOrWhiteSpace(jobId))", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + payload, err := os.ReadFile(test.path) + if err != nil { + t.Fatal(err) + } + text := string(payload) + for _, fragment := range test.required { + if !strings.Contains(text, fragment) { + t.Errorf("%s is missing terminal invariant %q", test.path, fragment) + } + } + for _, fragment := range test.forbidden { + if strings.Contains(text, fragment) { + t.Errorf("%s retains unsafe terminal pattern %q", test.path, fragment) + } + } + previous := -1 + for _, fragment := range test.ordered { + index := strings.Index(text, fragment) + if index <= previous { + t.Errorf( + "%s must order %q after the previous terminal guard", + test.path, + fragment, + ) + } + previous = index + } + }) + } +} + +func TestModExamplesOptIntoOutcomeReporting(t *testing.T) { + tests := map[string]string{ + "../examples/mods/bepinex-rin-npc/Plugin.cs": "outcome-reporting-v1", + "../examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/RinNpcMod.java": "outcome-reporting-v1", + "../examples/mods/luanti-rin-npc/init.lua": "outcome-reporting-v1", + "../examples/basic/main.go": "FeatureOutcomeReporting", + } + for path, marker := range tests { + payload, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(payload), marker) { + t.Errorf("%s does not opt into outcome reporting", path) + } + } +} + +func TestManagedModExamplesPersistAndValidateProposalAttempts(t *testing.T) { + tests := []struct { + name string + path string + required []string + attemptPersistMarker string + submitMarker string + jobPersistMarker string + getMarker string + repostStart string + repostEnd string + terminalStart string + terminalEnd string + terminalExclusion string + safeFallbackMarker string + fallbackCallMarker string + transactionStart string + transactionEnd string + appliedMarker string + outboxMarker string + effectMarker string + clearMarker string + }{ + { + name: "bepinex", + path: "../examples/mods/bepinex-rin-npc/Plugin.cs", + required: []string{ + "ProposalAttempt? proposalAttempt", + "RetainNewProposalAttempt", + "attempt.ProposeRequest", + "PersistProposalJobId(attempt, jobId)", + "GetProposalJobAsync(jobId)", + "ValidateJobIdentity(attempt, jobId, currentJob)", + "ValidateProposalIdentity", + "attempt.ProposeTick", + "var occurrenceTick = Math.Max(", + "InvalidateSessionIfNotFound", + "proposalAttempt = null", + "string.IsNullOrWhiteSpace(proposalId)", + }, + attemptPersistMarker: "proposalAttempt = retained;", + submitMarker: "rin.SubmitProposalJobAsync(", + jobPersistMarker: "PersistProposalJobId(attempt, jobId);", + getMarker: "rin.GetProposalJobAsync(jobId)", + repostStart: "private static bool ShouldRepostProposal", + repostEnd: "private static bool IsConfirmedSafeTerminal", + terminalStart: "private static bool IsConfirmedSafeTerminal", + terminalEnd: "private static RinApiException UnknownProposalOutcome", + terminalExclusion: "!AmbiguousProposalErrors.Contains(exception.Code)", + safeFallbackMarker: "when (IsConfirmedSafeTerminal(exception))", + fallbackCallMarker: "ProposalResolution.AuthoredFallback(exception.Code)", + transactionStart: "private bool PersistAuthoritativeTransaction(", + transactionEnd: "private bool PersistOutboxConversion(", + appliedMarker: "appliedOperations[operationId] = result;", + outboxMarker: "outcomeOutbox[operationId] = pending;", + effectMarker: "applyGameState();", + clearMarker: "proposalAttempt = null", + }, + { + name: "fabric", + path: "../examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/RinNpcMod.java", + required: []string{ + "ProposalAttempt proposalAttempt", + "retainNewProposalAttempt", + "attempt.proposeRequest", + "persistProposalJobId(registration, attempt, jobId)", + "getProposalJob(jobId)", + "validateJobIdentity(attempt, jobId, currentJob)", + "validateProposalIdentity", + "attempt.proposeTick", + "long occurrenceTick = Math.max(", + "invalidateSessionIfNotFound", + "registration.proposalAttempt = null", + }, + attemptPersistMarker: "registration.proposalAttempt = retained;", + submitMarker: "rin.submitProposalJob(attempt.proposeRequest)", + jobPersistMarker: "persistProposalJobId(registration, attempt, jobId);", + getMarker: "rin.getProposalJob(jobId)", + repostStart: "private static boolean shouldRepostProposal", + repostEnd: "private static boolean isConfirmedSafeTerminal", + terminalStart: "private static boolean isConfirmedSafeTerminal", + terminalEnd: "private static RinApiException unknownProposalOutcome", + terminalExclusion: "!AMBIGUOUS_PROPOSAL_ERRORS.contains(apiError.code())", + safeFallbackMarker: "if (isConfirmedSafeTerminal(cause))", + fallbackCallMarker: "ProposalResolution.authoredFallback(", + transactionStart: "private boolean persistAuthoritativeTransaction(", + transactionEnd: "private CompletableFuture flushOutcomeOutbox(", + appliedMarker: "appliedOperations.put(operationId, result);", + outboxMarker: "outcomeOutbox.put(operationId, pending);", + effectMarker: "applyGameState.run();", + clearMarker: "registration.proposalAttempt = null", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + payload, err := os.ReadFile(test.path) + if err != nil { + t.Fatal(err) + } + text := string(payload) + for _, required := range test.required { + if !strings.Contains(text, required) { + t.Errorf( + "%s is missing retained Proposal contract %q", + test.path, + required, + ) + } + } + + assertBefore := func(earlier, later, contract string) { + t.Helper() + earlierAt := strings.Index(text, earlier) + laterAt := strings.Index(text, later) + if earlierAt < 0 || laterAt < 0 || earlierAt >= laterAt { + t.Errorf("%s does not preserve %s", test.path, contract) + } + } + section := func(start, end string) string { + t.Helper() + startAt := strings.Index(text, start) + if startAt < 0 { + t.Errorf("%s is missing section start %q", test.path, start) + return "" + } + endAt := strings.Index(text[startAt+len(start):], end) + if endAt < 0 { + t.Errorf("%s is missing section end %q", test.path, end) + return "" + } + return text[startAt : startAt+len(start)+endAt] + } + + assertBefore( + test.attemptPersistMarker, + test.submitMarker, + "durable Proposal Attempt before its first POST", + ) + assertBefore( + test.jobPersistMarker, + test.getMarker, + "durable Job ID before its first GET", + ) + + repostSection := section(test.repostStart, test.repostEnd) + if !strings.Contains(repostSection, `"proposal_outcome_unknown"`) { + t.Errorf("%s does not route proposal_outcome_unknown through same-request recovery", test.path) + } + terminalSection := section(test.terminalStart, test.terminalEnd) + if !strings.Contains(terminalSection, test.terminalExclusion) { + t.Errorf("%s can reinterpret an ambiguous Proposal result as fallback-safe", test.path) + } + if !strings.Contains(text, test.safeFallbackMarker) { + t.Errorf("%s does not gate authored fallback on a confirmed safe terminal result", test.path) + } + if count := strings.Count(text, test.fallbackCallMarker); count != 1 { + t.Errorf( + "%s has %d authored-fallback call sites; want one guarded terminal path", + test.path, + count, + ) + } + + transactionSection := section(test.transactionStart, test.transactionEnd) + if !strings.Contains(transactionSection, test.clearMarker) { + t.Errorf("%s clears its Proposal Attempt outside the authoritative transaction", test.path) + } + transactionIndex := func(marker string) int { + t.Helper() + position := strings.Index(transactionSection, marker) + if position < 0 { + t.Errorf("%s authoritative transaction is missing %q", test.path, marker) + } + return position + } + appliedAt := transactionIndex(test.appliedMarker) + outboxAt := transactionIndex(test.outboxMarker) + effectAt := transactionIndex(test.effectMarker) + clearAt := transactionIndex(test.clearMarker) + if appliedAt < 0 || outboxAt < appliedAt || effectAt < outboxAt || clearAt < effectAt { + t.Errorf( + "%s does not atomically stage marker/outbox before effect and clear the attempt afterward", + test.path, + ) + } + }) + } + + t.Run("fabric-player-left-fallback-is-rejected", func(t *testing.T) { + const path = "../examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/RinNpcMod.java" + payload, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(payload) + start := strings.Index(text, "private CompletableFuture applyAuthoredFallbackTransaction(") + end := strings.Index(text, "private PendingOutcome commitPending(") + if start < 0 || end <= start { + t.Fatal("Fabric fallback transaction section is missing") + } + fallback := text[start:end] + for _, required := range []string{ + "if (player == null)", + "new AppliedAction(", + "false,", + "The player left before the authored fallback could be applied.", + } { + if !strings.Contains(fallback, required) { + t.Errorf("%s can report an accepted authored fallback without a player/effect: missing %q", path, required) + } + } + if strings.Contains(fallback, "AppliedAction applied = new AppliedAction(true, line);") { + t.Errorf("%s unconditionally accepts an authored fallback before checking its effect target", path) + } + }) +} + +func TestLuantiExampleResumesDurableProposalAttempts(t *testing.T) { + const path = "../examples/mods/luanti-rin-npc/init.lua" + payload, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(payload) + required := []string{ + "local proposal_attempts = {}", + "persist_new_proposal_attempt", + "persist_proposal_job_id", + "resume_proposal_attempt", + "submit_proposal_attempt(name, attempt, false)", + `code == "proposal_outcome_unknown"`, + `confirm_error.code) == "job_not_found"`, + "proposal_attempts[resolved_attempt.name] = nil", + "proposal_attempts[resolved_attempt.name] = resolved_attempt", + "sequence = 0,", + "local operation_id = session_id .. \".\" .. turn", + "((entry and entry.sequence or 0) + 1)", + "entry.sequence = math.max(entry.sequence, turn)", + "mark_session_missing", + "proposal_job_matches_attempt", + "proposal_matches_attempt", + "math.max(game_tick(), attempt.request.tick, proposal.tick)", + } + for _, fragment := range required { + if !strings.Contains(text, fragment) { + t.Errorf("%s is missing durable Proposal-attempt contract %q", path, fragment) + } + } + persistAt := strings.Index(text, "if not persist_new_proposal_attempt(name, attempt)") + submitAt := strings.LastIndex(text, "submit_proposal_attempt(name, attempt, true)") + if persistAt < 0 || submitAt < 0 || persistAt >= submitAt { + t.Errorf("%s must persist the complete attempt before its first POST", path) + } + sequenceAt := strings.Index(text, "entry.sequence = math.max(entry.sequence, turn)") + if sequenceAt < 0 || persistAt >= sequenceAt || sequenceAt >= submitAt { + t.Errorf("%s must persist the attempt before consuming its per-session sequence", path) + } + if count := strings.Count(text, "mark_session_missing("); count < 8 { + t.Errorf("%s marks session_not_found in only %d paths; want at least 8", path, count) + } + if strings.Contains(text, "client:submit_proposal_job({") { + t.Errorf("%s submits an ephemeral request instead of the retained attempt", path) + } + if strings.Contains(text, "local sequence =") { + t.Errorf("%s regressed to a collision-prone global turn sequence", path) + } +} diff --git a/compat/documentation_test.go b/compat/documentation_test.go index 5cbd46a..7f28a6f 100644 --- a/compat/documentation_test.go +++ b/compat/documentation_test.go @@ -17,6 +17,7 @@ func TestBilingualDocumentationPairs(t *testing.T) { {"../docs/architecture.md", "../docs/architecture.zh-CN.md"}, {"../docs/game-adapters.md", "../docs/game-adapters.zh-CN.md"}, {"../docs/model-policy.md", "../docs/model-policy.zh-CN.md"}, + {"../docs/outcome-reporting.md", "../docs/outcome-reporting.zh-CN.md"}, {"../docs/protocol-v1.md", "../docs/protocol-v1.zh-CN.md"}, {"../docs/rpg-events.md", "../docs/rpg-events.zh-CN.md"}, {"../docs/sdk-and-mods.md", "../docs/sdk-and-mods.zh-CN.md"}, @@ -46,6 +47,128 @@ func TestBilingualDocumentationPairs(t *testing.T) { } } +func TestPublicDocsUseOutcomeReportingSemantics(t *testing.T) { + required := map[string][]string{ + "../docs/outcome-reporting.md": { + "sole authority for world facts", + "must never apply the action again", + "proposal_base_mismatch", + "observed_tick", + "updated_tick", + "progress_accumulator", + "status_explicit", + "status_updated_tick", + "status_source_event_id", + "outcome_event_id", + "arbitration-v1", + "BatchCommitRequest.tick", + "unhandled saved Attempt", + }, + "../docs/outcome-reporting.zh-CN.md": { + "世界事实的唯一权威", + "不得重新应用动作", + "proposal_base_mismatch", + "observed_tick", + "updated_tick", + "progress_accumulator", + "status_explicit", + "status_updated_tick", + "status_source_event_id", + "outcome_event_id", + "arbitration-v1", + "BatchCommitRequest.tick", + "尚未处理的存档 Attempt", + }, + "../docs/protocol-v1.md": { + "job.error.code", + "proposal_outcome_unknown", + "Job is terminal, this code", + "exact same `request_id` and payload", + "two durable recovery states", + }, + "../docs/protocol-v1.zh-CN.md": { + "job.error.code", + "proposal_outcome_unknown", + "终态", + "完全相同的", + "两种持久恢复状态", + }, + "../docs/game-adapters.md": { + "`unresolved`", + "`rin_proposal_attempt(request_id)`", + "`rin_resume_proposal`", + "positively confirmed `not_found`", + "unconfigured example intentionally remains disabled", + "tick high-water", + }, + "../docs/game-adapters.zh-CN.md": { + "`unresolved`", + "`rin_proposal_attempt(request_id)`", + "`rin_resume_proposal`", + "确实 `not_found`", + "未配置时示例会有意", + "tick 高水位", + }, + } + for path, fragments := range required { + payload, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, fragment := range fragments { + if !strings.Contains(string(payload), fragment) { + t.Errorf("%s is missing outcome-reporting rule %q", path, fragment) + } + } + } + + optInDocs := []string{ + "../README.md", + "../README.en.md", + "../docs/architecture.md", + "../docs/architecture.zh-CN.md", + "../docs/game-adapters.md", + "../docs/game-adapters.zh-CN.md", + "../docs/outcome-reporting.md", + "../docs/outcome-reporting.zh-CN.md", + "../docs/protocol-v1.md", + "../docs/protocol-v1.zh-CN.md", + "../docs/rpg-events.md", + "../docs/rpg-events.zh-CN.md", + "../docs/sdk-and-mods.md", + "../docs/sdk-and-mods.zh-CN.md", + "../sdk/README.md", + "../sdk/README.zh-CN.md", + } + for _, path := range optInDocs { + payload, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(payload), "outcome-reporting-v1") { + t.Errorf("%s does not identify the outcome semantics as an explicit feature", path) + } + } + + prohibited := map[string]string{ + "../README.md": "游戏验证并调用 `commit` 后才生效", + "../README.en.md": "It takes effect only after the game validates it and calls", + "../docs/protocol-v1.zh-CN.md": "`status: pending`:必须 commit 才生效", + "../docs/protocol-v1.md": "the proposal has no effect until committed", + "../docs/game-adapters.zh-CN.md": "先应用、后提交流程", + "../docs/game-adapters.md": "apply-before-commit flow", + } + for path, phrase := range prohibited { + payload, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(payload), phrase) { + t.Errorf("%s retains obsolete commit-as-authorization wording %q", path, phrase) + } + } +} + func TestPublicDocumentationLanguage(t *testing.T) { required := map[string]string{ "../README.en.md": "> Game-native agent runtime.", diff --git a/compat/sdk_kits_test.go b/compat/sdk_kits_test.go index c0bec6f..1aa6abc 100644 --- a/compat/sdk_kits_test.go +++ b/compat/sdk_kits_test.go @@ -146,6 +146,133 @@ func TestSDKTransportSecurityGuardsRemainVisible(t *testing.T) { } } +func TestSDKJobWaitersValidateReturnedIdentity(t *testing.T) { + tests := []struct { + path string + required []string + }{ + { + path: "../sdk/python/src/rin_sdk/client.py", + required: []string{ + "_validate_job_identity", + "response_job_id != expected_job_id", + `proposal.get("session_id") != job["session_id"]`, + "_is_nonnegative_int64", + "_MAX_GENERATION_CONTENT_BYTES", + }, + }, + { + path: "../sdk/javascript/src/index.js", + required: []string{ + "validateJobIdentity", + "job.job_id !== expectedJobId", + "proposal.session_id !== job.session_id", + "Number.isSafeInteger(proposal.tick)", + "MAX_GENERATION_CONTENT_BYTES", + }, + }, + { + path: "../sdk/csharp/Rin.Client/RinClient.cs", + required: []string{ + "ValidateJobIdentity", + "responseJobId != expectedJobId", + "proposalSessionId != jobSessionId", + "TryNonnegativeInt64Property", + "MaxGenerationContentBytes", + }, + }, + { + path: "../sdk/java/src/main/java/io/github/sunrioa/rin/RinClient.java", + required: []string{ + "validateJobIdentity", + "!id.equals(expectedJobId)", + `Objects.equals(proposal.get("session_id"), job.get("session_id"))`, + "isNonnegativeSignedInt64", + "MAX_GENERATION_CONTENT_BYTES", + }, + }, + { + path: "../sdk/lua/rin.lua", + required: []string{ + "resolve_job(job, result_kind, expected_job_id)", + "job.job_id ~= expected_job_id", + "proposal.session_id ~= job.session_id", + "is_nonnegative_signed_int64", + "max_generation_content_bytes", + }, + }, + } + for _, test := range tests { + payload, err := os.ReadFile(test.path) + if err != nil { + t.Fatal(err) + } + text := string(payload) + for _, required := range test.required { + if !strings.Contains(text, required) { + t.Errorf("%s is missing job identity guard %q", test.path, required) + } + } + } + + testSources := []string{ + "../sdk/python/tests/test_client.py", + "../sdk/javascript/test/client.test.js", + "../sdk/csharp/Rin.Client.Tests/Program.cs", + "../sdk/java/test/io/github/sunrioa/rin/RinClientTest.java", + "../sdk/lua/test_client.lua", + } + for _, path := range testSources { + payload, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(payload) + for _, required := range []string{"crossed", "malformed", "GET", "DELETE", "invalid_job"} { + if !strings.Contains(text, required) { + t.Errorf("%s is missing crossed/malformed race coverage marker %q", path, required) + } + } + } +} + +func TestCSharpJobStatusUsesRawJSONStrings(t *testing.T) { + payload, err := os.ReadFile("../sdk/csharp/Rin.Client/RinClient.cs") + if err != nil { + t.Fatal(err) + } + text := string(payload) + for _, required := range []string{ + "RequiredRawJobStatus(canceledJob)", + "RequiredRawJobStatus(job)", + "property.ValueKind != JsonValueKind.String", + "var status = property.GetString()", + } { + if !strings.Contains(text, required) { + t.Errorf("C# SDK is missing raw job-status guard %q", required) + } + } + for _, forbidden := range []string{ + `TextProperty(canceledJob, "status"`, + `TextProperty(job, "status"`, + } { + if strings.Contains(text, forbidden) { + t.Errorf("C# SDK normalizes decision-bearing job status through %q", forbidden) + } + } + + tests, err := os.ReadFile("../sdk/csharp/Rin.Client.Tests/Program.cs") + if err != nil { + t.Fatal(err) + } + testText := string(tests) + for _, required := range []string{`canceled\\u0000`, `" canceled "`, "job_outcome_unknown"} { + if !strings.Contains(testText, required) { + t.Errorf("C# SDK tests are missing pseudo-status coverage marker %q", required) + } + } +} + func TestExampleModsPreserveGameAuthority(t *testing.T) { tests := []struct { path string @@ -153,19 +280,43 @@ func TestExampleModsPreserveGameAuthority(t *testing.T) { forbidden []string }{ { - path: "../examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/RinNpcMod.java", - required: []string{"ALLOWED_ACTIONS", "activePlayers", "waitForProposal", "server.execute", "rin.commit", "candidate_actions"}, - forbidden: []string{"Runtime.getRuntime().exec", "ProcessBuilder", ".join()"}, + path: "../examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/RinNpcMod.java", + required: []string{ + "ALLOWED_ACTIONS", "activePlayers", "waitForProposal", "server.execute", + "rin.commit", "candidate_actions", `text(proposal, "id")`, + "appliedOperations", "outcomeOutbox", "flushOutcomeOutbox", + "persistAuthoritativeTransaction", "PRODUCTION PERSISTENCE HOOK", + }, + forbidden: []string{ + "Runtime.getRuntime().exec", "ProcessBuilder", ".join()", + `text(proposal, "proposal_id")`, "persistOperationState", + }, }, { - path: "../examples/mods/bepinex-rin-npc/Plugin.cs", - required: []string{"AllowedActions", "WaitForProposalAsync", "mainThread.Enqueue", "CommitAsync", "NpcActionReady"}, - forbidden: []string{"Config.Bind(\"Connection\", \"Token\"", ".Result", ".Wait()"}, + path: "../examples/mods/bepinex-rin-npc/Plugin.cs", + required: []string{ + "AllowedActions", "WaitForProposalAsync", "mainThread.Enqueue", + "CommitAsync", "NpcActionReady", `RequiredString(proposal, "id")`, + "appliedOperations", "outcomeOutbox", "FlushOutcomeOutboxAsync", + "PersistAuthoritativeTransaction", "PRODUCTION PERSISTENCE HOOK", + }, + forbidden: []string{ + "Config.Bind(\"Connection\", \"Token\"", ".Result", ".Wait()", + `RequiredString(proposal, "proposal_id")`, "PersistOperationState", + }, }, { - path: "../examples/mods/luanti-rin-npc/init.lua", - required: []string{"core.request_http_api", "local_origin", "allowed_actions", "wait_for_proposal", "client:commit"}, - forbidden: []string{"secure.trusted_mods", "request.headers.Authorization =", "os.execute"}, + path: "../examples/mods/luanti-rin-npc/init.lua", + required: []string{ + "core.request_http_api", "local_origin", "allowed_actions", + "wait_for_proposal", "client:commit", "type(proposal.id)", + "applied_operations", "outcome_outbox", "flush_outcome_outbox", + "persist_authoritative_transaction", "PRODUCTION PERSISTENCE HOOK", + }, + forbidden: []string{ + "secure.trusted_mods", "request.headers.Authorization =", "os.execute", + "proposal.proposal_id", "persist_operation_state", + }, }, } for _, test := range tests { diff --git a/docs/README.md b/docs/README.md index 3055d07..1e343be 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ individual consuming games. | Topic | English | 简体中文 | | --- | --- | --- | | Architecture and authority boundary | [Architecture](architecture.md) | [架构](architecture.zh-CN.md) | +| Proposal, application, and outcome transactions | [Action outcome reporting](outcome-reporting.md) | [动作结果记账](outcome-reporting.zh-CN.md) | | HTTP and state contract | [Protocol v1](protocol-v1.md) | [协议 v1](protocol-v1.zh-CN.md) | | Online-model configuration | [Model policy](model-policy.md) | [模型策略](model-policy.zh-CN.md) | | Ren'Py, Godot, and Unity | [Game adapters](game-adapters.md) | [游戏适配器](game-adapters.zh-CN.md) | diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 761ec94..a6b646d 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -7,6 +7,7 @@ Rin 文档按稳定的公共契约组织,不以某个使用方项目作为叙 | 主题 | 简体中文 | English | | --- | --- | --- | | 架构与权威边界 | [架构](architecture.zh-CN.md) | [Architecture](architecture.md) | +| Proposal、应用与结果事务 | [动作结果记账](outcome-reporting.zh-CN.md) | [Action outcome reporting](outcome-reporting.md) | | HTTP 与状态契约 | [协议 v1](protocol-v1.zh-CN.md) | [Protocol v1](protocol-v1.md) | | 在线模型配置 | [模型策略](model-policy.zh-CN.md) | [Model policy](model-policy.md) | | Ren'Py、Godot 与 Unity | [游戏适配器](game-adapters.zh-CN.md) | [Game adapters](game-adapters.md) | diff --git a/docs/architecture.md b/docs/architecture.md index 2e885cd..f1951b3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,7 +12,7 @@ flowchart LR G["Game engine\nworld authority"] -->|Observation| R["Rin runtime\nmemory + goals + policy"] R -->|ActionProposal| V["Schema + boundary + freshness validation"] V -->|candidate action only| G - G -->|Commit accepted/rejected| R + G -->|Applied/rejected outcome report| R R --> E["Hash-chained event log"] R --> S["Verified snapshot"] R -->|"bounded prompt packet"| P["Optional model provider"] @@ -38,9 +38,13 @@ fields, and identifiers cannot contain path separators. `runtime.Engine` is a deterministic state machine. Each session has its own lock. Policy execution happens outside that lock, so a slow remote model does not block new observations or state reads. Legacy sessions use revision and -head hash for staleness. Sessions with `arbitration-v1` use a -`world_revision` that advances only when world facts change, allowing several -actors to propose in parallel during one turn. +head hash to detect stale Proposals before application. Sessions opting into +`outcome-reporting-v1` use the game-authoritative apply-then-report lifecycle +and occurrence-time merge described below. Sessions with +`arbitration-v1` use a `world_revision` that advances with authoritative +Observations and settled Outcomes, allowing several actors to propose in +parallel during one turn. Once the game has handled an Outcome, Rin records it +even when the report arrives after state has advanced. Detailed memory keeps a fixed window. `memory-archive-v1` compresses the oldest batch into a deterministic summary with source IDs, tick range, and @@ -125,10 +129,13 @@ local content. Model output never becomes canon automatically. Ren'Py, Godot, and Unity adapters translate JSON/HTTP and engine-specific asynchrony without copying the runtime state machine. Online results have -`committable=true`. When the sidecar is unavailable, an adapter chooses an -authored fallback from the current candidate list and marks it -`committable=false`; the game must not send a local `offline.*` ID to -`/commit`. +`committable=true`, meaning the game may report that Proposal ID after handling +it, not that Rin authorizes execution. An adapter may choose an authored +fallback from the current candidate list only when it knows submission never +created an online Proposal (for example, the sidecar was disabled or the +initial connection was refused), and marks it `committable=false`. A submit, +poll, timeout, or cancellation with an unconfirmed outcome fails closed; the +game must not send a local `offline.*` ID to `/commit`. The Ren'Py worker registry, Godot `HTTPRequest`, and Unity coroutines exist only in process memory. A game save stores snapshots and plain results, never @@ -137,12 +144,15 @@ threads, futures, sockets, HTTP objects, or API tokens. ### Multi-actor coordination The game supplies the upper bound and semantic scope of candidate goals. A -policy may only recommend adopting one; only an accepted commit writes the -goal into an actor. The game's region or simulation system updates activity -state. Dormant actors never wake themselves. Arbitration stably sorts -proposals at the same world revision and records conflicts, but it does not -execute actions. The game may adjust or reject them and then report actual -outcomes through an atomic batch commit. +policy may only recommend adopting one; the game applies it and reports an +accepted Commit before Rin writes the goal into an actor. The game's region or +simulation system updates activity state. Dormant actors never wake +themselves. Arbitration stably sorts proposals at the same world revision and +records conflicts, but it does not execute actions. With +`outcome-reporting-v1`, the game may adjust or reject them and then report +actual outcomes through an atomic Batch Commit. +See [action outcome reporting](outcome-reporting.md) for the full transaction +and Outbox rules. This lets Rin support visual novels, RPG NPCs, and simulation residents without taking responsibility for pathfinding, collision, quest rules, or a @@ -183,8 +193,10 @@ coordinated store instead of sharing a JSONL directory. ## NPC scheduling -Each actor declares `think_every_ticks`. After an action is accepted, -`next_think_tick = commit.tick + think_every_ticks`. A game may call +Each actor declares `think_every_ticks`. After the game applies an action and +reports an accepted Commit, +`next_think_tick = max(current, commit.tick + think_every_ticks)`, so a late +report cannot move scheduling backward. A game may call `/v1/scheduler/due` when entering a region, ending a turn, advancing time, or handling a critical event. It should never poll a model from render frames. @@ -195,8 +207,14 @@ only scheduling time, never boundaries or the action allowlist. - Game saves should store snapshots returned by Rin, not internal file paths. - A snapshot carries the content-pack binding and state hash. -- Restore clears uncommitted proposals so an old-world action cannot execute - after loading. +- With `outcome-reporting-v1`, Restore retains pending proposals so a saved, + unhandled Proposal Attempt can resume, and so a game-save Outcome Outbox can + report actions already applied before the save. Restored proposals never + authorize execution; the game must use its persisted Attempt and + applied-operation marker to distinguish the states, revalidate any action + that was not already handled, and never repeat one that was. +- Sessions without that Feature retain legacy Restore behavior and clear + proposals. - Committed events, memories, facts, goal progress, and scheduling ticks are restored. - A new data directory may import a snapshot; its local event chain then diff --git a/docs/architecture.zh-CN.md b/docs/architecture.zh-CN.md index e7cd1cd..3771db1 100644 --- a/docs/architecture.zh-CN.md +++ b/docs/architecture.zh-CN.md @@ -11,7 +11,7 @@ flowchart LR G["Game engine\nworld authority"] -->|Observation| R["Rin runtime\nmemory + goals + policy"] R -->|ActionProposal| V["Schema + boundary + freshness validation"] V -->|candidate action only| G - G -->|Commit accepted/rejected| R + G -->|Applied/rejected outcome report| R R --> E["Hash-chained event log"] R --> S["Verified snapshot"] R -->|"bounded prompt packet"| P["Optional model provider"] @@ -28,7 +28,7 @@ flowchart LR ### 运行时 -`runtime.Engine` 是确定性状态机。每个会话单独加锁;Policy 在锁外执行,因此远程模型变慢不会阻塞新的观察或读状态。旧会话继续用 revision/head hash 判断过期;启用 `arbitration-v1` 的会话使用只在世界事实变化时前进的 `world_revision`,因此同一轮多个角色可以并行提出动作。 +`runtime.Engine` 是确定性状态机。每个会话单独加锁;Policy 在锁外执行,因此远程模型变慢不会阻塞新的观察或读状态。旧会话继续用 revision/head hash 判断应用前的 Proposal 是否过期;显式启用 `outcome-reporting-v1` 的会话采用下文“游戏先处理、再回报”和发生时间合并语义。启用 `arbitration-v1` 的会话使用随权威 Observation 和 Outcome 结算前进的 `world_revision`,因此同一轮多个角色可以并行提出动作。游戏已经处理的 Outcome 即使延迟到达也会被记录,不再作为应用前 Proposal 重新判断新鲜度。 详细记忆保持固定窗口;`memory-archive-v1` 将最旧批次压成带来源 ID、tick 范围和原因的确定性摘要,并在摘要达到上限后继续分层合并。`belief-conflicts-v1` 为每个角色保留最多八条来源声明,同时维持旧 `beliefs` 字段作为当前选中投影。两者都完全由事件重放恢复,不依赖向量数据库。 @@ -71,13 +71,13 @@ Generation 只保证传输、大小和顶层 JSON Object 合法。各游戏仍 ### 游戏适配器 -Ren'Py、Godot 和 Unity 适配器只转换 JSON/HTTP 与各自的异步机制,不复制 Runtime 状态机。在线结果带 `committable=true`;Sidecar 不可用时,适配器从游戏本次候选列表选择 authored fallback,标记 `committable=false`,游戏不得把本地 `offline.*` ID 发给 `/commit`。 +Ren'Py、Godot 和 Unity 适配器只转换 JSON/HTTP 与各自的异步机制,不复制 Runtime 状态机。在线结果带 `committable=true`,表示游戏处理后可向 Sidecar 回报该 Proposal ID,而不是 Rin 授权执行。只有确定在线提交从未创建 Proposal(例如 Sidecar 已禁用或初始连接被拒绝)时,适配器才能从游戏本次候选列表选择 authored fallback,并标记 `committable=false`;提交、轮询、超时或取消结果尚未确认时必须 fail closed。游戏不得把本地 `offline.*` ID 发给 `/commit`。 Ren'Py worker registry、Godot `HTTPRequest` 和 Unity coroutine 都只存在于进程内。游戏存档保存 Snapshot 与普通结果,不保存线程、Future、Socket、HTTP 对象或 API Token。 ### 多角色协调 -候选目标仍由游戏提供上限和语义范围,Policy 只能建议采用;只有 accepted Commit 才把目标写进 Actor。Activity 状态由游戏的区域或模拟系统更新,Dormant 角色不会自行唤醒。Arbitration 对同一 world revision 的 Proposal 做稳定排序并记录冲突,但不执行动作;游戏可以调整、拒绝,再以原子 Batch Commit 汇报实际结果。 +候选目标仍由游戏提供上限和语义范围,Policy 只能建议采用;启用 `outcome-reporting-v1` 后,只有游戏已经应用并以 accepted Commit 回报的目标才写进 Actor。Activity 状态由游戏的区域或模拟系统更新,Dormant 角色不会自行唤醒。Arbitration 对同一 world revision 的 Proposal 做稳定排序并记录冲突,但不执行动作;游戏可以调整、拒绝,再以原子 Batch Commit 汇报实际结果。完整事务与 Outbox 规则见[动作结果记账](outcome-reporting.zh-CN.md)。 这使 Rin 可以服务视觉小说、RPG NPC 和模拟居民,同时不承担寻路、碰撞、任务规则或 Scene Tree 等引擎职责。 @@ -103,7 +103,10 @@ rin-data/ ## NPC 调度 -每个 Actor 声明 `think_every_ticks`。动作被接受后,`next_think_tick = commit.tick + think_every_ticks`。游戏可在区域进入、回合结束、分钟推进或关键事件后调用 `/v1/scheduler/due`,不应在渲染帧中轮询模型。 +每个 Actor 声明 `think_every_ticks`。游戏应用动作并以 accepted Commit 回报后, +`next_think_tick = max(current, commit.tick + think_every_ticks)`,因此延迟结果 +不会让调度倒退。游戏可在区域进入、回合结束、分钟推进或关键事件后调用 +`/v1/scheduler/due`,不应在渲染帧中轮询模型。 紧急事件可在 propose 请求中设置 `urgent: true`,但它只绕过调度时间,不绕过边界和动作白名单。 @@ -111,7 +114,12 @@ rin-data/ - 游戏存档应保存 Rin 返回的 Snapshot,而不是内部文件路径。 - Snapshot 带内容包 Binding 和状态哈希。 -- Restore 会清空未提交 Proposal,避免读档后执行旧世界状态上的动作。 +- 启用 `outcome-reporting-v1` 后,Restore 会保留 pending Proposal,既让存档中 + 尚未处理的 Proposal Attempt 能恢复,也让 Outcome Outbox 能补报读档前已经 + 应用的动作。恢复出的 Proposal 不授权执行;游戏必须依赖持久化 Attempt 和 + applied-operation marker 区分两种状态,重新校验尚未处理的动作,并且绝不 + 重做已经处理的动作。 +- 未启用该 Feature 的 Session 保留旧版 Restore 行为并清空 Proposal。 - 已提交事件、记忆、事实、目标进度和调度 tick 会恢复。 - 新数据目录可以导入 Snapshot;此时本地事件链从一条 restore 事件开始。 - 重复载入同一存档时,调用方应让 restore request ID 同时绑定 Snapshot hash 与当前 Sidecar head,以区分网络重试和真正的再次回档。 diff --git a/docs/game-adapters.md b/docs/game-adapters.md index d7696be..dcdd5ca 100644 --- a/docs/game-adapters.md +++ b/docs/game-adapters.md @@ -12,8 +12,32 @@ keeping the same authority split: An adapter result adds two local fields around the protocol proposal: -- `committable=true`: the proposal came from the current Sidecar session and may be sent to `/v1/action/commit` after the game applies it. -- `committable=false`: the game used its authored offline fallback. Apply it locally, but do not send its `offline.*` ID to Rin. When the Sidecar recovers, report the resulting event through `observe`. +- `committable=true`: the proposal came from the current Sidecar session and + its result may be sent to `/v1/action/commit` after the game handles it. This + is not authorization from Rin to execute it. +- `committable=false`: there is no Rin Proposal that can be committed. Apply a + local fallback only when `source=offline` and a proposal is present. A + canceled/error result has no action to apply. Never send an `offline.*` ID + to Rin; after recovery, report an applied fallback through `observe`. + +Timeout or a lost submit/poll/cancel response is outcome-unknown, not offline: +retry the same request/job identity and do not choose a fallback until the +absence of an online Proposal is confirmed. + +Persist a Proposal Attempt before the first submit. It contains the complete +byte-equivalent Propose request, its game operation/sequence identity, and the +Job ID as soon as `202` supplies one. A later interaction must resume that +attempt instead of creating a new request. Remove it only in the authoritative +transaction that applies or rejects the returned Proposal and stores the +applied marker plus Outcome Outbox entry. Both an unresolved Proposal Attempt +and a nonempty Outcome Outbox block new turns. + +For new Sessions, request `outcome-reporting-v1`; only then does Commit record +an already handled outcome rather than use the legacy pre-commit semantics. +The game should apply or reject the action and write a local Outcome Outbox +entry in one authoritative transaction, then report from that Outbox to Rin. +On a network failure, retry only the same `request_id` and never apply the +action again. See [action outcome reporting](outcome-reporting.md). ## Ren'Py @@ -58,23 +82,25 @@ request_id = rin_schedule_proposal({ }, fallback_action_id="respond.wait") ``` -`rin_proposal_status(request_id)` returns `pending`, `ready`, or `missing`; `rin_consume_proposal(request_id)` returns one plain JSON-compatible result. `rin_cancel_proposal` propagates cancellation to the Job API. +`rin_proposal_status(request_id)` returns `pending`, `ready`, `unresolved`, or `missing`; `rin_consume_proposal(request_id)` returns one plain JSON-compatible result only after a safe terminal outcome. For `pending` or `unresolved`, persist the plain record from `rin_proposal_attempt(request_id)` with the game save. After restart, pass that record to `rin_resume_proposal`; it recovers a known Job first and permits at most one exact same-request POST when Rin confirms that Job is absent. An unresolved attempt is neither consumable nor locally cancelable. `rin_cancel_proposal` propagates cancellation to the Job API for a running process-local worker. The Python client also exposes `commit_batch`, `set_actor_activity`, `arbitrate`, `timeline`, `replay`, and the structured-generation methods. Generation must run in the same process-local background pattern as proposals. `generate_json` accepts only the provider-free Rin request contract and returns one decoded JSON object plus bounded operational metadata. A game that persists request records should allowlist only the fields it needs; provider model names are useful for explicit probes but should not be copied into gameplay saves. -Threads, cancellation events, HTTP objects, and registries are process-local. Never assign them to `default`, persistent data, rollback state, or a save object. Only store accepted protocol snapshots and plain result dictionaries. +Threads, cancellation events, HTTP objects, and registries are process-local. Never assign them to `default`, persistent data, rollback state, or a save object. Store only accepted protocol snapshots, plain result dictionaries, and the plain stable Proposal Attempt records described above. Native Ren'Py tests are offline unless `RIN_LIVE_TEST_ENABLED=1`, even if a developer shell contains a configured endpoint. ## Godot 4 -Add [the client](../examples/godot/rin_client.gd) as a node or autoload. `propose_with_fallback` awaits `HTTPRequest` signals and timer ticks, so it does not block rendering. The [NPC example](../examples/godot/example_npc.gd) shows the complete propose, game apply, and commit sequence. +Add [the client](../examples/godot/rin_client.gd) as a node or autoload. `propose_with_fallback` awaits `HTTPRequest` signals and timer ticks, so it does not block rendering. The [NPC example](../examples/godot/example_npc.gd) shows the propose, game-application, and outcome-report sequence. Its storage methods are deliberate integration hooks, not an in-memory persistence implementation: replace `_load_authoritative_state`, initialization, Attempt, transaction, conversion, and acknowledgement hooks with the game's save system. Until the load hook reports either one valid state or a positively confirmed `not_found`, the example disables turns and performs no Rin request. + +Restore the run ID, stable Create request, operation sequence, protocol-tick high-water mark, complete Proposal Attempt, applied markers, and report Outbox as one authoritative game state before enabling play. The high-water mark prevents a reset engine frame counter from producing `tick_regressed` after restart. An I/O, parse, or schema error is not `not_found`; fail closed rather than minting a new identity. On a real `not_found`, persist the complete initialized state before publishing its new run ID. Godot owns navigation, animation, combat, inventory, and dialogue rendering. Helpers for activity, due actors, arbitration, batch commit, timeline, and replay are coroutines; call activity on simulation/region changes, not every frame. The adapter caps response bytes, disables redirects, and accepts plaintext HTTP only for an exact loopback host and valid port. ## Unity -Attach [RinClient.cs](../examples/unity/RinClient.cs) to a GameObject. It uses `UnityWebRequest` coroutines and a capped streaming download handler; no JSON or networking package is required. [RinNpcExample.cs](../examples/unity/RinNpcExample.cs) shows the same apply-before-commit flow. +Attach [RinClient.cs](../examples/unity/RinClient.cs) to a GameObject. It uses `UnityWebRequest` coroutines and a capped streaming download handler; no JSON or networking package is required. [RinNpcExample.cs](../examples/unity/RinNpcExample.cs) shows the same apply-before-report flow and the same startup recovery gate. Wire its `LoadAuthoritativeState` and persistence methods to the game's save provider; the unconfigured example intentionally remains disabled instead of treating a storage failure as a new playthrough. A restored Unity state must carry the same run ID, stable Create request, sequence, tick high-water, Proposal Attempt, applied markers, and Outcome Outbox described above. Unity's `JsonUtility` adapter exposes serializable DTOs for activity, scheduling, arbitration, batch commit, and timeline. Since `JsonUtility` cannot represent actor-ID keyed maps, its Replay helper returns the verified Snapshot header; projects that need the complete replayed state should parse the same endpoint with their existing dictionary-capable JSON package. Games that use action parameter maps can likewise extend the serializable request classes without changing the wire protocol. diff --git a/docs/game-adapters.zh-CN.md b/docs/game-adapters.zh-CN.md index 0fa3075..f0084ae 100644 --- a/docs/game-adapters.zh-CN.md +++ b/docs/game-adapters.zh-CN.md @@ -11,11 +11,28 @@ 适配器会在协议提案外增加两个本地字段: -- `committable=true`:提案来自当前 Sidecar 会话,游戏应用后可以发送到 - `/v1/action/commit`。 -- `committable=false`:游戏使用了自己编写的离线回退。可以在本地应用, - 但不能把 `offline.*` ID 发送给 Rin。Sidecar 恢复后,应通过 `observe` - 报告实际产生的事件。 +- `committable=true`:提案来自当前 Sidecar 会话,游戏处理后可以把结果发送到 + `/v1/action/commit`;它不是 Rin 的执行授权。 +- `committable=false`:当前没有可 Commit 的 Rin Proposal。只有 + `source=offline` 且返回 Proposal 时才能应用本地回退;canceled/error + 结果没有可执行动作。不能把 `offline.*` ID 发送给 Rin,Sidecar 恢复后 + 应通过 `observe` 报告已经应用的 fallback。 + +提交、轮询或取消响应超时/丢失时,结果属于 outcome-unknown,而不是 offline; +应以相同 request/job 身份恢复,确认不存在在线 Proposal 前不得选择 fallback。 + +首次提交前应持久化 Proposal Attempt,其中包含字节等价的完整 Propose +Request、游戏 Operation/Sequence 身份,并在 `202` 返回后立即补上 Job ID。 +后续交互必须恢复这条 Attempt,不能创建新 Request。只有在同一个权威事务中 +应用或拒绝返回的 Proposal,并写入 Applied Marker 与 Outcome Outbox 后,才能 +移除 Attempt。未决 Proposal Attempt 与未清空的 Outcome Outbox 都会阻止新 +Turn。 + +新 Session 必须请求 `outcome-reporting-v1`,此时 Commit 才是已处理结果记账, +而不是旧版的提交前语义。游戏应在同一个权威事务中应用 +或拒绝动作并写入本地 Outcome Outbox,再从 Outbox 向 Rin 回报。网络失败时只 +使用相同 `request_id` 重报,绝不能再次应用动作。详细规则见 +[动作结果记账](outcome-reporting.zh-CN.md)。 ## Ren'Py @@ -61,9 +78,14 @@ request_id = rin_schedule_proposal({ }, fallback_action_id="respond.wait") ``` -`rin_proposal_status(request_id)` 返回 `pending`、`ready` 或 `missing`; -`rin_consume_proposal(request_id)` 返回一个普通 JSON 兼容结果; -`rin_cancel_proposal` 会把取消传递给 Job API。 +`rin_proposal_status(request_id)` 返回 `pending`、`ready`、`unresolved` 或 +`missing`;只有得到安全终态后,`rin_consume_proposal(request_id)` 才返回 +普通 JSON 兼容结果。状态为 `pending` 或 `unresolved` 时,应把 +`rin_proposal_attempt(request_id)` 返回的普通记录随游戏存档持久化;重启后 +把该记录交给 `rin_resume_proposal`,它会先恢复已知 Job,并且仅在 Rin 明确 +确认该 Job 不存在时最多重发一次完全相同的请求。未决 Attempt 既不能消费, +也不能作为本地可确认取消处理。运行中的进程内 worker 则可由 +`rin_cancel_proposal` 把取消传递给 Job API。 Python 客户端还提供 `commit_batch`、`set_actor_activity`、`arbitrate`、 `timeline`、`replay` 和结构化生成方法。Generation 必须与 Proposal 一样 @@ -73,7 +95,7 @@ Python 客户端还提供 `commit_batch`、`set_actor_activity`、`arbitrate`、 线程、取消事件、HTTP 对象和注册表都只属于当前进程。不要把它们赋给 `default`、persistent 数据、rollback 状态或存档对象。只保存已接受的协议 -Snapshot 和普通结果字典。 +Snapshot、普通结果字典,以及上文所述的普通稳定 Proposal Attempt 记录。 即使开发者 shell 配置了端点,Ren'Py 原生测试也默认离线;只有 `RIN_LIVE_TEST_ENABLED=1` 才允许真实网络。 @@ -82,8 +104,18 @@ Snapshot 和普通结果字典。 将[客户端](../examples/godot/rin_client.gd)添加为节点或 autoload。 `propose_with_fallback` 等待 `HTTPRequest` signal 和 timer tick,不会阻塞 -渲染。[NPC 示例](../examples/godot/example_npc.gd)展示完整的提案、游戏应用 -和提交顺序。 +渲染。[NPC 示例](../examples/godot/example_npc.gd)展示提案、游戏应用和 +结果回报顺序。示例中的存储方法是有意保留的集成 Hook,并不是内存持久化 +实现;请用游戏存档系统替换 `_load_authoritative_state`、初始化、Attempt、 +事务、转换和确认 Hook。在加载 Hook 返回一个有效状态,或明确确认 +`not_found` 之前,示例会禁用 Turn,且不会向 Rin 发请求。 + +启用玩法前,必须把 run ID、稳定 Create 请求、操作序号、协议 tick 高水位、 +完整 Proposal Attempt、applied marker 和 report Outbox 作为同一个游戏权威 +状态恢复。tick 高水位可防止引擎帧计数在重启归零后产生 +`tick_regressed`。I/O、解析或 schema 错误不等于 `not_found`,此时必须 +fail closed,不能生成新身份;只有确实 `not_found` 时,才可先持久化完整 +初始化状态,再发布新 run ID。 Godot 负责导航、动画、战斗、背包和对白渲染。Activity、到期角色、仲裁、 批量提交、时间线和回放 helper 都是 coroutine;只在模拟或区域变化时更新 @@ -95,7 +127,11 @@ loopback 主机和合法端口接受明文 HTTP。 将 [RinClient.cs](../examples/unity/RinClient.cs) 挂载到 GameObject。它使用 `UnityWebRequest` coroutine 和有上限的流式下载处理器,不需要额外 JSON 或网络包。[RinNpcExample.cs](../examples/unity/RinNpcExample.cs)展示同样的 -先应用、后提交流程。 +先应用、后回报流程及同样的启动恢复门禁。请把 +`LoadAuthoritativeState` 和各持久化方法接入游戏存档;未配置时示例会有意 +保持禁用,而不会把存储失败当作新周目。恢复的 Unity 状态必须包含上述同一 +run ID、稳定 Create 请求、序号、tick 高水位、Proposal Attempt、applied +marker 和 Outcome Outbox。 Unity 的 `JsonUtility` 适配器为 Activity、调度、仲裁、批量提交和时间线 提供可序列化 DTO。由于 `JsonUtility` 无法表示以 Actor ID 为键的 map, diff --git a/docs/outcome-reporting.md b/docs/outcome-reporting.md new file mode 100644 index 0000000..7db22d4 --- /dev/null +++ b/docs/outcome-reporting.md @@ -0,0 +1,162 @@ +# Action outcome reporting + +[English](outcome-reporting.md) | [简体中文](outcome-reporting.zh-CN.md) + +This document defines the Proposal, game application, and Commit transaction +semantics for `rin.protocol/v1`. New sessions must include +`outcome-reporting-v1` in `CreateSessionRequest.features` to opt in. Sessions +without that feature keep the historical commit-as-fresh-head checks and +clamped, arrival-ordered reducer behavior so existing event logs replay +unchanged. + +## One world authority + +The game engine is the sole authority for world facts. A Rin Proposal is a +pre-application suggestion. Commit records the result after the game has +handled that Proposal: + +```text +Rin produces a Proposal +→ the game revalidates the action, target, and preconditions on its owning thread +→ the game applies the action or rejects it +→ the game adds the result to its durable Outcome Outbox +→ the game reports the accepted/rejected result to Rin with Commit +``` + +Rin does not execute a game action through Commit, and Commit success must not +cause the game to execute the action again. + +For v1 wire compatibility, `/v1/action/commit`, `CommitRequest`, `accepted`, +and the adapter-local `committable` field retain their existing names. They +describe outcome-recording capability, not authorization or execution by Rin. + +## Field semantics + +- `accepted=true` means the game confirms that the proposed action actually + took effect and became canon. +- `accepted=false` means the game confirms that the proposed world effect did + not occur. `outcome` may contain a bounded audit reason; observations learned + from the failure should be sent separately through `observe`. A rejected + report must not carry `facts` or `goal_updates`. +- `status=pending` means Rin has not yet received and settled the game result. + It does not mean the action is waiting for Rin to activate it. +- `committable=true` means the Proposal ID can be reported to the current + sidecar. It is not execution authorization and does not replace the game's + local freshness check before application. +- `tick` is the game tick when the action happened or was rejected. It cannot + predate the Proposal tick, but it may be older than the current Session tick + when the report arrives. + +Before application, the game must re-read Session state and check its own +authoritative preconditions. With `arbitration-v1`, require +`state.world_revision == proposal.based_on_world_revision` (or arbitrate the +proposal set). Without arbitration, require the retained Proposal to remain +`pending` and `state.revision == proposal.created_revision`. The +`based_on_revision` and `based_on_head_hash` fields identify the state before +the Proposal event and are audit context; they are not compared directly with +the post-Proposal Session head. If freshness or game preconditions fail, the +game must not apply the action and may report `accepted=false`. + +A Proposal Job timeout is not proof that no Proposal exists. Retry submission +or lookup with the same request ID/job ID, and consume the final DELETE +response: cancellation may lose a race to a Proposal that was already +persisted. While delivery or cancellation is unconfirmed, fail closed and do +not execute an offline fallback. A fallback is safe only when the integration +knows no online Proposal was created (for example, the Sidecar was disabled or +the initial connection was definitively refused). + +## Late outcomes + +After the game applies an action, observations, other actor outcomes, or network +delay may already have advanced Rin's state. That report is a late +authoritative fact, not an error. Commit does not reject it merely because the +current Revision, World Revision, or Session tick has advanced. + +Rin merges accepted late outcomes by their game occurrence tick: + +- scheduling never moves backward; +- accepted actions and episodic memories remain ordered by occurrence time; +- a Fact is stamped with server-owned `observed_tick`, so an older report + cannot overwrite a newer value for the same subject and predicate; +- a Goal is stamped with `updated_tick`; its server-owned + `progress_accumulator` retains the unclamped sum so positive and negative + deltas remain commutative; `status_explicit` distinguishes a game-supplied + status from automatic active/completed projection, while + `status_updated_tick` and `status_source_event_id` order explicit statuses + independently from progress-only updates (event ID breaks a same-tick tie); +- resolved Proposals carry `outcome_event_id` and `outcome_tick`, including + rejected outcomes, so their event IDs remain auditable while retained. + +These fields are response/state metadata and request DTOs must not set them +(leave them omitted or at their zero value). Callers supply occurrence time +through the enclosing Observe or Commit `tick`; Rin derives the metadata from +that authoritative request field. + +`state_changed` while producing a Proposal and `proposal_stale` during +pre-application Arbitration still reject obsolete suggestions. They are not +used to reject an outcome that the game has already handled. + +## Batch outcomes + +`/v1/action/commit-batch` requires `arbitration-v1` and atomically records a set +of outcomes. The apply-then-report and late-outcome rules in this document also +require `outcome-reporting-v1`. Every item must come from the same original +`based_on_world_revision`, but that revision may be older than Rin's current +revision when the report arrives. Every item also shares the enclosing +`BatchCommitRequest.tick` as its actual occurrence tick; group outcomes by tick +or use individual Commit calls when their occurrence times differ. Mixing +original world revisions returns `proposal_base_mismatch` without partial +mutation. + +## Outbox and retries + +Before an asynchronous Proposal submit, persist a separate Proposal Attempt +containing the complete request, game operation identity, and optional Job ID. +`proposal_outcome_unknown` keeps that attempt and blocks new turns. Resume its +exact request/job identity until Rin returns a Proposal or confirms a terminal +no-Proposal state; a terminal Job carrying this code is still unresolved. +When a Proposal succeeds, remove the attempt only in the same authoritative +transaction described below; this closes the crash window between receiving a +Proposal and persisting its eventual report. + +The game should apply an action and persist an Outcome Outbox entry in the same +authoritative transaction. An entry contains at least: + +- stable and unique Commit `request_id` and `event_id` values; +- `proposal_id`, occurrence tick, accepted, and outcome; +- any tags, facts, and goal updates needed by the report. + +An accepted report contains at most one update for each Goal. This removes +array-order ambiguity when occurrence-time updates merge. + +On a timeout or temporary error, the game only reports the same entry again +with the same `request_id`; it must never apply the action again. Remove an +entry only after success or an explicit duplicate response. Drain the Outbox +before creating a game save, or save all unacknowledged entries together with +the matching Rin Snapshot and Proposal Attempts. Restore retains pending +Proposals both so an unhandled saved Attempt can resume and be revalidated, and +so an already-handled operation's saved Outbox can still report its complete +Facts, Goal updates, recent action, and scheduling effects. A restored Proposal +does not authorize execution: the persisted Attempt and applied-operation +marker distinguish an unhandled action from one that must never run again. + +If the sidecar session cannot be restored and therefore truly has no matching +Proposal, `observe` is a degraded reconciliation path for the authoritative +event's memory and Facts at its original occurrence tick. It cannot recreate +proposal-specific Goal deltas, recent-action history, or scheduling; represent +the resulting absolute world state as Facts and do not claim a complete Commit +reconciliation. Never repeat the action to obtain a new Proposal. An +`offline.*` Proposal can never be committed; report the actual fallback event +through `observe` after the sidecar recovers. + +## Compatibility migration + +An integration that currently commits first and applies only after success +must create a new session with `outcome-reporting-v1`, then migrate to game-side +validate and apply or reject first, followed by Commit. Request fields and HTTP +paths remain wire-compatible. The feature deliberately changes reducer +semantics and stored state metadata; it is never added automatically to an +existing session. Sessions and event logs without it keep their historical +replay result. Feature-enabled Proposal, Fact, and Goal state may include the +optional occurrence metadata described above; older pre-feature snapshots +remain readable under legacy semantics. diff --git a/docs/outcome-reporting.zh-CN.md b/docs/outcome-reporting.zh-CN.md new file mode 100644 index 0000000..e6318d2 --- /dev/null +++ b/docs/outcome-reporting.zh-CN.md @@ -0,0 +1,133 @@ +# 动作结果记账 + +[English](outcome-reporting.md) | [简体中文](outcome-reporting.zh-CN.md) + +本文定义 `rin.protocol/v1` 的 Proposal、游戏应用与 Commit 事务语义。新 Session +必须在 `CreateSessionRequest.features` 中加入 `outcome-reporting-v1` 才会启用; +未启用的 Session 保持历史上的 head 新鲜度检查、逐步截断和按到达顺序合并, +从而让旧事件日志继续得到相同的重放结果。 + +## 唯一权威 + +游戏引擎是世界事实的唯一权威。Rin 的 Proposal 是应用前建议,Commit 是游戏 +处理 Proposal 后的结果记账: + +```text +Rin 产生 Proposal +→ 游戏在权威线程重新验证动作、目标和前置条件 +→ 游戏应用动作,或者决定拒绝 +→ 游戏把结果加入自己的持久 Outcome Outbox +→ 游戏用 Commit 向 Rin 回报 accepted/rejected 结果 +``` + +Rin 不会通过 Commit 执行游戏动作,Commit 成功也不应触发游戏再次执行动作。 + +为保持 v1 线格式兼容,路径 `/v1/action/commit`、类型名 +`CommitRequest`、字段 `accepted` 和适配器本地字段 `committable` 保持不变。 +它们表示结果记账能力,不表示 Rin 授权或执行动作。 + +## 字段语义 + +- `accepted=true`:游戏确认 Proposal 对应的动作已经实际生效并成为 Canon。 +- `accepted=false`:游戏确认没有产生该动作的世界效果。拒绝原因可以放入 + `outcome` 供审计;由失败产生的新观察应另行调用 `observe`。拒绝结果不得 + 携带 `facts` 或 `goal_updates`。 +- `status=pending`:Rin 尚未收到并结算游戏结果,不表示动作等待 Rin 激活。 +- `committable=true`:Proposal ID 可向当前 Sidecar 回报,不是执行授权, + 也不替代游戏在应用前的本地新鲜度检查。 +- `tick`:动作实际发生或被拒绝的游戏 tick。它不得早于 Proposal tick, + 但可以早于结果到达时的当前 Session tick。 + +游戏必须在应用前重新读取 Session state,并检查自己的权威前置条件。启用 +`arbitration-v1` 时,应要求 +`state.world_revision == proposal.based_on_world_revision`(或先仲裁整组 +Proposal);未启用仲裁时,应要求保留的 Proposal 仍为 `pending`,且 +`state.revision == proposal.created_revision`。`based_on_revision` 与 +`based_on_head_hash` 指向 Proposal 事件之前的状态,只用于审计,不能直接与 +Proposal 写入后的 Session head 比较。若新鲜度或游戏前置条件失效,游戏不得 +应用动作,并可回报 `accepted=false`。 + +Proposal Job 超时不等于 Proposal 不存在。应使用相同 request ID/job ID +重试提交或查询,并消费 DELETE 的最终响应:取消可能输给已经持久化的 +Proposal。在投递或取消尚未确认时必须 fail closed,不得执行离线 fallback。 +只有接入层确定没有创建在线 Proposal(例如 Sidecar 已禁用,或初始连接被明确 +拒绝)时,fallback 才安全。 + +## 延迟结果 + +游戏应用动作后,Observation、其他角色结果或网络延迟可能已经推进 Rin 状态。 +这种结果是延迟到达的权威事实,不是错误。Commit 不会只因当前 +Revision、World Revision 或 Session tick 已前进而拒绝它。 + +Rin 按游戏中的发生 tick 合并已接受的延迟结果: + +- 调度时间只会前进,不会倒退; +- 已接受动作与情节记忆保持按发生时间排序; +- Fact 由服务端写入 `observed_tick`,同一 subject/predicate 的旧报告不会 + 覆盖更新的事实; +- Goal 由服务端写入 `updated_tick`,并用 `progress_accumulator` 保留未截断 + 的累计值,使正负 progress delta 保持可交换;较旧的显式 status 不能覆盖 + 较新的 status;`status_explicit` 用于区分游戏显式状态和由进度自动投影的 + active/completed,`status_updated_tick` 与 `status_source_event_id` 则让 + 显式状态独立于纯进度更新排序(同 tick 用事件 ID 决定); +- 已解决 Proposal 会保存 `outcome_event_id` 与 `outcome_tick`,拒绝结果也 + 一样,因此在 Proposal 保留期间可以审计其事件 ID。 + +这些字段是响应/状态元数据,请求 DTO 不得主动设置(保持省略或零值)。 +调用方通过外层 Observe 或 Commit 的 `tick` 提供发生时间,Rin 据此派生 +这些元数据。 + +Proposal 生成期间的 `state_changed` 和应用前 Arbitration 的 +`proposal_stale` 仍会拒绝旧建议;它们不能用于拒绝游戏已经处理的结果。 + +## 批量结果 + +`/v1/action/commit-batch` 必须启用 `arbitration-v1`,并原子记录一组结果; +本文的“先处理、后记账”和延迟 Outcome 语义还要求启用 +`outcome-reporting-v1`。所有 Item 必须来自同一个原始 +`based_on_world_revision`,但该版本可以早于报告到达时 Rin 的当前版本。所有 +Item 还会共享外层 `BatchCommitRequest.tick` 作为实际发生 tick;发生时间不同 +时必须按 tick 分组,或者分别调用 Commit。混合不同原始版本会以 +`proposal_base_mismatch` 拒绝整个请求且不产生部分修改。 + +## Outbox 与重试 + +异步提交 Proposal 前,游戏还应持久化一条独立的 Proposal Attempt,保存完整 +Request、游戏 Operation 身份与可选 Job ID。`proposal_outcome_unknown` 必须 +保留这条 Attempt 并阻止新 Turn;携带该错误码的终态 Job 仍然属于未决状态。 +游戏应持续恢复完全相同的 request/job 身份,直到 Rin 返回 Proposal 或确认终态 +中不存在 Proposal。成功拿到 Proposal 后,也只能在下述同一个权威事务中移除 +Attempt,从而关闭“收到 Proposal 到持久化结果报告”之间的崩溃窗口。 + +游戏应在同一个权威事务中应用动作并持久化 Outcome Outbox 项。Outbox 至少保存: + +- 稳定且唯一的 Commit `request_id` 和 `event_id`; +- `proposal_id`、发生 tick、accepted、outcome; +- 回报所需的 tags、facts 和 goal updates。 + +一个 accepted 回报对每个 Goal 最多包含一条 update,避免发生时间合并受数组 +顺序影响。 + +网络超时或暂时错误时,游戏只使用同一 `request_id` 重报,不得重新应用动作。 +收到成功或明确 duplicate 后才能删除 Outbox 项。创建游戏存档前应先排空 +Outbox,或者把未确认项、Proposal Attempt 与匹配的 Rin Snapshot 一起保存。 +Restore 会保留 pending Proposal,既让尚未处理的存档 Attempt 能恢复并重新 +校验,也让已经处理的 Operation 通过存档 Outbox 完整补报 Fact、Goal update、 +近期动作和调度影响。恢复出的 Proposal 绝不授权执行动作;游戏必须用持久化 +Attempt 与 applied-operation marker 区分尚未处理的动作和绝不能重做的动作。 + +若 Sidecar Session 无法恢复、因而确实不存在匹配 Proposal,`observe` 只是降级 +对账路径:它能按原始发生 tick 恢复权威事件的记忆和 Fact,但不能重建 +Proposal 专属的 Goal delta、近期动作或调度。此时应把最终的绝对世界状态表达 +为 Fact,不得宣称已经完成等价 Commit 对账,也不得为了获得新 Proposal 而重做 +动作。`offline.*` Proposal 始终不能 Commit;Sidecar 恢复后通过 `observe` +报告实际 fallback 事件。 + +## 兼容迁移 + +旧接入若采用“先 Commit、成功后再 Apply”,应为新 Session 显式启用 +`outcome-reporting-v1`,再迁移为“游戏先验证并 Apply/Reject,随后 Commit”。 +请求字段与 HTTP 路径保持线格式兼容;该 Feature 会有意改变 reducer 语义和 +持久状态元数据,因此绝不会自动加到已有 Session。未启用 Feature 的旧 Session +与事件日志继续保持历史重放结果。启用后的 Proposal、Fact 和 Goal 状态可能带有 +上述可选发生时间元数据;Feature 启用前的 Snapshot 按旧语义继续可读。 diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index c7ed3c5..0b25196 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -55,7 +55,7 @@ with Windows file names. "content_hash": "sha256:..." }, "seed": 42, - "features": ["memory-archive-v1", "belief-conflicts-v1"], + "features": ["outcome-reporting-v1", "memory-archive-v1", "belief-conflicts-v1"], "actors": [ { "id": "npc.mira", @@ -102,10 +102,16 @@ session. `/health` returns the supported values: by the current request; - `actor-activity-v1`: enable region and awake/dormant lifecycle; - `arbitration-v1`: enable world revision, multi-actor arbitration, and atomic - batch commit. + batch commit; +- `outcome-reporting-v1`: make the game the sole outcome authority, allow late + reports, and merge Facts, Goals, memories, actions, and scheduling by game + occurrence time. -Legacy sessions that omit this field keep v0.4 behavior, including replay -hashes and JSON shape. +Legacy sessions that omit a feature keep the corresponding historical reducer +behavior and replay result. In particular, `outcome-reporting-v1` is never +enabled automatically for an existing event log. Feature-enabled returned +state may include optional occurrence metadata; tolerant JSON decoders must +ignore fields they do not recognize. ## Observe @@ -141,6 +147,15 @@ Only actors in `observer_ids` receive the memory. If a fact has a `visibility` list, it is written only to observers on that list, preventing NPCs from learning events they did not perceive. +With `outcome-reporting-v1`, Rin stamps each returned Fact with the enclosing +request tick as `observed_tick`; callers do not set that field in requests +(omitted or zero is accepted). An authoritative Observation may then arrive +after the Session tick has advanced, including save/restore reconciliation. +Rin preserves the original `tick`, orders memory by occurrence time, and +prevents older Facts from replacing newer values. Sessions without the Feature +keep the legacy monotonic-tick and arrival-order behavior and do not populate +`observed_tick`. + ## Propose `POST /v1/agent/propose` @@ -180,7 +195,8 @@ The returned proposal includes: - `recalled_memory_ids` and `goal_id`: auditable evidence; - `rationale`: one character-facing sentence for UI, not hidden model reasoning; -- `status: pending`: the proposal has no effect until committed; +- `status: pending`: Rin has not received the game's outcome; it is not an + action waiting for Rin to activate it; - `policy_source`: `model`, `model-cache`, `boundary-guard`, `deterministic-fallback`, or an offline source. @@ -223,10 +239,25 @@ Status is `queued`, `running`, `succeeded`, `failed`, `stale`, or `canceled`. On success, `proposal` contains a normal ActionProposal. Failure returns only a safe error code, never a provider response body. +Clients must inspect `job.error.code` before treating a terminal `failed` Job +as proof that no Proposal exists. `proposal_outcome_unknown` means Rin could +not determine or confirm whether the Proposal event became durable. Although +the Job is terminal, this code is not a confirmed no-Proposal result: keep the +durable Proposal Attempt, re-POST its exact same `request_id` and payload, then +resume GET using the returned (normally unchanged) Job ID. Do not execute an +offline fallback or start another Session mutation until reconciliation. A +direct synchronous uncertainty may surface as HTTP `500`; other mutations +blocked behind that uncertainty return HTTP `409` with the same code. + Cancel with: `DELETE /v1/jobs/{job_id}` +The response is the stable terminal job state. Canceling a running proposal +waits for its in-flight Engine mutation to settle; if the Proposal already won +the durable-write race, DELETE returns `succeeded` with that Proposal instead +of hiding it as canceled. Clients must consume this response. + Repeated submissions with the same session and `request_id` return the same job. A different payload returns `request_id_conflict`. The queue is bounded; when full it returns `429 jobs_queue_full`. @@ -284,6 +315,12 @@ content. `POST /v1/action/commit` +Commit records the authoritative outcome after the game applies or rejects a +Proposal; it is not permission to execute. The game must revalidate and handle +the action on its owning thread before sending Commit. `accepted=true` means +the action actually took effect. `accepted=false` means that proposed effect +did not occur. + ```json { "protocol_version": "rin.protocol/v1", @@ -302,7 +339,26 @@ content. Accepting a proposal records the action outcome, updates scheduling, marks recalled memories, and advances the associated goal by one. Rejecting a -proposal does not modify actor memories, facts, or goals. +proposal does not modify actor memories, facts, or goals; send facts learned +from a failed attempt separately through `observe`. With +`outcome-reporting-v1`, rejected reports must omit `facts` and `goal_updates`, +and accepted reports may contain at most one update per Goal. + +With `outcome-reporting-v1`, `tick` is when the action happened or was +rejected. It cannot predate the Proposal tick, but it may be older than the +current Session tick when the report arrives. Rin records an Outcome the game +already handled even if the current Revision or World Revision has advanced +since the Proposal. Resolved Proposal state includes `outcome_event_id` and +`outcome_tick`; Facts include `observed_tick`, and Goals include `updated_tick` +plus an unclamped `progress_accumulator`, a `status_explicit` marker, and +independent `status_updated_tick`/`status_source_event_id` ordering. These +server-owned values preserve occurrence-time ordering and order-independent +progress deltas when reports arrive late. Sessions without the Feature retain +the legacy stale/tick validation and arrival-order reducer. Retry a timeout or +temporary failure only with the same `request_id`; never execute the game +action again. See +[action outcome reporting](outcome-reporting.md) for the complete merge, +Outbox, late-outcome, and migration rules. ## Living-world coordination @@ -345,9 +401,11 @@ revision before calling `POST /v1/world/arbitrate`: Results are deterministically ordered by target priority, tick, actor ID, and proposal ID, then marked `selected` or `deferred`. Arbitration records a recommendation and never changes the game world directly. After applying -selected actions, the game may use `POST /v1/action/commit-batch` to commit at -most one result per actor. If any entry is stale or invalid, the entire batch -is rejected without partial mutation. +selected actions, the game may use `POST /v1/action/commit-batch` to record at +most one result per actor. Every item must come from the same original +`based_on_world_revision`, although Rin's current revision may have advanced +when the report arrives. Mixed original revisions or any invalid item reject +the entire batch without partial mutation. ## Scheduler @@ -386,7 +444,14 @@ Restore: ``` Restore rejects snapshots with an invalid hash, different session ID, or -different binding, and clears pending proposals. +different binding. With `outcome-reporting-v1`, it retains pending proposals +for two durable recovery states: an unresolved Proposal Attempt received before +the game handled it, or an already-handled operation whose saved Outcome Outbox +still needs to report. A restored Proposal never authorizes execution. The game +must use its saved Attempt and applied-operation marker to distinguish those +states, revalidate an unhandled action before handling it, and never repeat an +already-handled action. Sessions without the Feature retain legacy behavior +and clear restored proposals. When a game repeatedly loads the same save, the restore `request_id` should bind both the target snapshot hash and the sidecar's current head hash. A @@ -424,8 +489,10 @@ endpoint. | `401` | `unauthorized` | Missing or incorrect Bearer token | | `404` | `session_not_found` / `unknown_actor` | Entity does not exist | | `404` | `revision_not_found` | Replay revision does not exist | -| `409` | `state_changed` / `proposal_stale` | Base state changed | +| `409` | `state_changed` / `proposal_stale` | Base state changed during Proposal generation or pre-application Arbitration | +| `409` | `proposal_base_mismatch` | A Batch Outcome mixes original world revisions | | `409` | `actor_not_due` | Actor has not reached its thinking tick | +| `200` Job / `409` / `500` | `proposal_outcome_unknown` | Proposal durability is unresolved; retain the exact attempt and reconcile it without fallback | | `422` | `no_safe_action` | Boundary triggered without a safe candidate | | `413` | `body_too_large` | Request exceeds the body limit | | `429` | `jobs_queue_full` / `jobs_capacity` | Proposal queue or retention is full | diff --git a/docs/protocol-v1.zh-CN.md b/docs/protocol-v1.zh-CN.md index cf8248e..f0faa28 100644 --- a/docs/protocol-v1.zh-CN.md +++ b/docs/protocol-v1.zh-CN.md @@ -49,7 +49,7 @@ ID 长度为 1–96,只允许字母、数字、`.`、`_`、`-`,从源头阻 "content_hash": "sha256:..." }, "seed": 42, - "features": ["memory-archive-v1", "belief-conflicts-v1"], + "features": ["outcome-reporting-v1", "memory-archive-v1", "belief-conflicts-v1"], "actors": [ { "id": "npc.mira", @@ -90,9 +90,13 @@ Binding 防止另一版本剧情或 Mod 的状态被静默恢复到当前游戏 - `belief-conflicts-v1`:保留角色私有的互相矛盾说法及来源; - `goal-candidates-v1`:允许 Policy 从本次请求给出的候选小目标中提出一个; - `actor-activity-v1`:启用区域和 awake/dormant 生命周期; -- `arbitration-v1`:启用 world revision、多角色仲裁与原子批量 commit。 +- `arbitration-v1`:启用 world revision、多角色仲裁与原子批量 commit; +- `outcome-reporting-v1`:让游戏成为结果的唯一权威,允许延迟回报,并按游戏 + 发生时间合并 Fact、Goal、记忆、动作和调度。 -省略该字段的旧 Session 保持 v0.4 行为,重放 hash 和 JSON 形状不变。 +未启用某 Feature 的旧 Session 保持对应的历史 reducer 行为与重放结果; +`outcome-reporting-v1` 尤其不会自动加入已有事件日志。启用 Feature 后返回状态 +可能增加可选发生时间元数据;JSON 解码器必须忽略不认识的字段。 ## 提交观察 @@ -124,7 +128,15 @@ Binding 防止另一版本剧情或 Mod 的状态被静默恢复到当前游戏 } ``` -只有 `observer_ids` 中的角色获得这段记忆。Fact 若带 `visibility`,只写入名单中的观察者,避免 NPC 知道未见过的事情。 +只有 `observer_ids` 中的角色获得这段记忆。Fact 若带 `visibility`,只写入 +名单中的观察者,避免 NPC 知道未见过的事情。 + +启用 `outcome-reporting-v1` 后,返回状态里的 Fact 会由 Rin 使用外层请求 tick +写入 `observed_tick`;请求中应省略该字段或保持零值。此时权威 Observation +可以在 Session tick 已前进后到达,包括存档恢复后的对账。Rin 保留原始 +`tick`,按发生时间排列记忆,并阻止旧 Fact 替换更新值。未启用该 Feature +的 Session 保留旧版 tick 单调约束和到达顺序语义,也不填充 +`observed_tick`。 ## 生成提案 @@ -163,7 +175,7 @@ Binding 防止另一版本剧情或 Mod 的状态被静默恢复到当前游戏 - `action`:原样取自游戏候选动作,Policy 不能添权。 - `recalled_memory_ids`、`goal_id`:可审计依据。 - `rationale`:给 UI 使用的一句角色化说明,不是模型隐藏推理。 -- `status: pending`:必须 commit 才生效。 +- `status: pending`:Rin 尚未收到游戏处理结果;它不是等待 Rin 激活的动作。 - `policy_source`:`model`、`model-cache`、`boundary-guard`、`deterministic-fallback` 或离线来源。 Policy 运行期间不会持有会话锁。如果新观察先到达,调用返回 `state_changed`;客户端应以新的 `request_id` 重试。 @@ -198,10 +210,23 @@ Policy 运行期间不会持有会话锁。如果新观察先到达,调用返 状态为 `queued`、`running`、`succeeded`、`failed`、`stale` 或 `canceled`。成功时 `proposal` 字段包含正常 ActionProposal;失败时只返回安全错误码,不包含供应商正文。 +客户端把终态 `failed` 当成“确认没有 Proposal”之前,必须检查 +`job.error.code`。`proposal_outcome_unknown` 表示 Rin 无法判断或确认 +Proposal 事件是否已经持久化;即使 Job 已是终态,该错误也不是 +no-Proposal 证明。游戏必须保留持久 Proposal Attempt,用完全相同的 +`request_id` 和 payload 重新 POST,再用返回的(通常不变的)Job ID 继续 +GET。完成对账前不得执行离线 fallback,也不得开始其他 Session mutation。 +同步调用第一次暴露这种不确定性时可能返回 HTTP `500`;被该不确定性阻塞的 +其他 mutation 会以 HTTP `409` 返回同一错误码。 + 取消: `DELETE /v1/jobs/{job_id}` +响应是稳定的终态。取消运行中的 Proposal Job 时会等待正在进行的 Engine +mutation 结算;若 Proposal 已赢得持久写入竞态,DELETE 会返回带 Proposal 的 +`succeeded`,而不是把它隐藏成 canceled。客户端必须消费该响应。 + 相同 Session 和 `request_id` 的重复提交返回同一个 Job。若 payload 不同则返回 `request_id_conflict`。Job 队列有界,满载时返回 `429 jobs_queue_full`。 ## 结构化生成任务 @@ -243,6 +268,10 @@ DELETE /v1/generation/jobs/{job_id} `POST /v1/action/commit` +Commit 是游戏应用或拒绝 Proposal 后的权威结果记账,不是执行许可。游戏必须 +在自己的权威线程重新验证并处理动作,再发送 Commit。`accepted=true` 表示 +动作已经实际生效;`accepted=false` 表示该动作效果没有发生。 + ```json { "protocol_version": "rin.protocol/v1", @@ -259,7 +288,22 @@ DELETE /v1/generation/jobs/{job_id} } ``` -接受提案会记录行动结果、更新调度、标记记忆被召回,并让关联目标自动前进 1。拒绝提案不会修改角色记忆、事实和目标。 +接受提案会记录行动结果、更新调度、标记记忆被召回,并让关联目标自动前进 1。拒绝提案不会修改角色记忆、事实和目标;失败中观察到的新事实应另行 +`observe`。启用 `outcome-reporting-v1` 时,拒绝结果必须省略 `facts` 与 +`goal_updates`,接受结果对每个 Goal 最多包含一条 update。 + +启用 `outcome-reporting-v1` 后,`tick` 是动作发生或被拒绝的时间,不得早于 +Proposal tick,但可以早于报告到达时的 Session tick。Proposal 产生后的 Rin +Revision 或 World Revision 即使已经前进,游戏已处理的 Outcome 仍会被记录。 +已解决 Proposal 状态带有 `outcome_event_id` 和 `outcome_tick`,Fact 带有 +`observed_tick`,Goal 带有 `updated_tick` 与未截断的 +`progress_accumulator`;这些服务端字段用于在延迟到达时保持按发生时间 +合并,并使正负进度增量不依赖到达顺序;`status_explicit` 标记状态是否由 +游戏显式给出,`status_updated_tick` 和 `status_source_event_id` 独立排序 +显式状态。未启用该 Feature 的 Session 保留旧版 stale/tick 校验和到达顺序 +reducer。超时或暂时错误只能使用相同 `request_id` 重报,不能重新执行游戏 +动作。完整合并、Outbox、延迟结果和迁移规则见 +[动作结果记账](outcome-reporting.zh-CN.md)。 ## Living World 协调 @@ -295,7 +339,7 @@ DELETE /v1/generation/jobs/{job_id} } ``` -结果以目标优先级、tick、actor ID、proposal ID 确定性排序,给出 `selected` 或 `deferred`。仲裁是建议记录,不直接改变游戏世界。游戏应用选中动作后,可用 `POST /v1/action/commit-batch` 一次提交每个角色最多一个结果;任何一项失效都会拒绝整个批次,不产生部分修改。 +结果以目标优先级、tick、actor ID、proposal ID 确定性排序,给出 `selected` 或 `deferred`。仲裁是建议记录,不直接改变游戏世界。游戏应用选中动作后,可用 `POST /v1/action/commit-batch` 一次提交每个角色最多一个结果。所有 Item 必须来自同一个原始 `based_on_world_revision`,但报告到达时当前版本可以已经前进;混合原始版本或任何无效 Item 都会拒绝整个批次,不产生部分修改。 ## 调度器 @@ -332,7 +376,13 @@ Restore: } ``` -Restore 拒绝 hash 错误、Session ID 不同或 Binding 不同的快照,并清空 pending Proposal。 +Restore 拒绝 hash 错误、Session ID 不同或 Binding 不同的快照。启用 +`outcome-reporting-v1` 时,它会为两种持久恢复状态保留 pending Proposal: +游戏处理前收到但尚未结算的 Proposal Attempt,以及动作已经处理、但存档中的 +Outcome Outbox 仍待补报的 Operation。恢复 Proposal 绝不授权游戏执行它。 +游戏必须依靠存档中的 Attempt 与 applied-operation marker 区分两种状态; +尚未处理的动作要重新校验后再处理,已处理动作绝不能重复执行。未启用该 +Feature 的 Session 保留旧版行为并清空恢复出的 Proposal。 当游戏反复载入同一存档时,Restore `request_id` 应同时绑定目标 Snapshot hash 和 Sidecar 当前 head hash。这样一次网络重试仍然幂等,而从后来状态再次读档会产生新的 Restore 事件并真正回退。 @@ -362,8 +412,10 @@ Replay 会包含该 revision 已存在的角色记忆和剧情状态,因此沿 | `401` | `unauthorized` | Bearer Token 缺失或错误 | | `404` | `session_not_found` / `unknown_actor` | 实体不存在 | | `404` | `revision_not_found` | Replay revision 不存在 | -| `409` | `state_changed` / `proposal_stale` | 基础状态已改变 | +| `409` | `state_changed` / `proposal_stale` | Proposal 生成或应用前仲裁的基础状态已改变 | +| `409` | `proposal_base_mismatch` | Batch Outcome 混合了不同的原始 world revision | | `409` | `actor_not_due` | 尚未到该角色的思考 tick | +| `200` Job / `409` / `500` | `proposal_outcome_unknown` | Proposal 持久化结果未决;保留原 Attempt 且不得 fallback,使用相同身份对账 | | `422` | `no_safe_action` | 边界触发但游戏没提供安全动作 | | `413` | `body_too_large` | 请求超过大小限制 | | `429` | `jobs_queue_full` / `jobs_capacity` | 异步队列或保留区已满 | diff --git a/docs/rpg-events.md b/docs/rpg-events.md index b975906..1d0fca0 100644 --- a/docs/rpg-events.md +++ b/docs/rpg-events.md @@ -39,14 +39,16 @@ Quest state remains in the game. Rin may remember bounded facts such as: "predicate": "stage", "object": "materials-delivered", "visibility": ["npc.harbor.foreman"], - "confidence": 100, - "source_event_id": "event.quest.repair-bridge.12" + "confidence": 100 } ``` Use an observation when a task changes, then advertise only actions legal in the current stage. A proposal such as `offer-next-step` is dialogue intent; the game still decides whether the quest advances, rewards are granted, or inventory changes. -Rumors should be facts with lower confidence and a source event. When two actors disagree, keep both observations rather than silently promoting one to world truth. +Rin derives each stored Fact's `source_event_id` from the enclosing +Observation or Commit `event_id`; callers omit it in requests. Rumors should +use lower confidence. When two actors disagree, keep both observations rather +than silently promoting one to world truth. ## Candidate actions @@ -65,12 +67,21 @@ For high-impact actions, advertise an intent such as `request-trade` or `attempt ## Apply and commit +This sequence is for Sessions that explicitly enable +`outcome-reporting-v1`; legacy Sessions retain their previous Commit +semantics. + 1. Reject a stale proposal if the target moved, died, left visibility, changed faction, or lost required resources. -2. Apply the selected action through normal gameplay systems. -3. Commit the observed outcome, including failure or rejection. -4. Send resulting observations only to actors who perceived them. +2. Apply the selected action through normal gameplay systems, or decide to reject it. +3. Persist the actual result in the game's Outcome Outbox as part of the same authoritative transaction. +4. Commit from the Outbox. A later Rin head does not invalidate an outcome that already happened. +5. Send resulting observations only to actors who perceived them. + +Rejected proposals are useful audit history. Commit with `accepted=false` when the action was still a valid character intention but the game denied it. Do not commit adapter-local `offline.*` proposals; report their actual outcome later through `observe`. -Rejected proposals are useful character history. Commit with `accepted=false` when the action was still a valid character intention but the game denied it. Do not commit adapter-local `offline.*` proposals; report their actual outcome later through `observe`. +On a Commit timeout or temporary failure, report the same Outbox entry again; +never execute the action again. See +[action outcome reporting](outcome-reporting.md) for the complete rules. ## Boundaries and player safety diff --git a/docs/rpg-events.zh-CN.md b/docs/rpg-events.zh-CN.md index bc0a700..591cc99 100644 --- a/docs/rpg-events.zh-CN.md +++ b/docs/rpg-events.zh-CN.md @@ -46,8 +46,7 @@ Fact 使用自己的 `visibility` 白名单。这样,听到声音的角色不 "predicate": "stage", "object": "materials-delivered", "visibility": ["npc.harbor.foreman"], - "confidence": 100, - "source_event_id": "event.quest.repair-bridge.12" + "confidence": 100 } ``` @@ -55,8 +54,9 @@ Fact 使用自己的 `visibility` 白名单。这样,听到声音的角色不 `offer-next-step` 这类 Proposal 只是对白意图;是否推进任务、发放奖励或 修改背包仍由游戏决定。 -传闻应作为低置信度并带来源事件的 Fact。两个角色意见冲突时保留两条 -Observation,不要悄悄把其中一条提升为世界真相。 +Rin 会根据外层 Observation 或 Commit 的 `event_id` 生成存储后 Fact 的 +`source_event_id`,调用方应在请求中省略该字段。传闻应使用较低置信度。 +两个角色意见冲突时保留两条 Observation,不要悄悄把其中一条提升为世界真相。 ## 候选动作 @@ -78,15 +78,22 @@ Observation,不要悄悄把其中一条提升为世界真相。 ## 应用与提交 +以下顺序只适用于显式启用 `outcome-reporting-v1` 的 Session;旧 Session +继续使用原有 Commit 语义。 + 1. 若目标移动、死亡、离开可见范围、改变阵营或失去所需资源,拒绝过期提案。 -2. 通过正常玩法系统应用选定动作。 -3. Commit 实际观察到的结果,包括失败或拒绝。 -4. 只向确实感知结果的 Actor 发送后续 Observation。 +2. 通过正常玩法系统应用选定动作,或决定拒绝。 +3. 在同一权威事务中把实际结果写入游戏自己的 Outcome Outbox。 +4. 从 Outbox Commit 实际结果;状态已经前进不会使已发生结果失效。 +5. 只向确实感知结果的 Actor 发送后续 Observation。 -被拒绝的 Proposal 仍是有价值的角色历史。若动作作为角色意图仍然有效, +被拒绝的 Proposal 仍是有价值的审计历史。若动作作为角色意图仍然有效, 只是被游戏规则拒绝,应以 `accepted=false` Commit。不要 Commit 适配器 本地的 `offline.*` Proposal;之后通过 `observe` 报告它们的实际结果。 +Commit 超时或暂时失败时只重报同一 Outbox 项,不得再次执行动作。完整规则见 +[动作结果记账](outcome-reporting.zh-CN.md)。 + ## 边界与玩家安全 模型侧意图永远不能覆盖本地的同意、骚扰、购买、不可逆任务选择、PvP、 diff --git a/docs/sdk-and-mods.md b/docs/sdk-and-mods.md index ac5115e..321fa81 100644 --- a/docs/sdk-and-mods.md +++ b/docs/sdk-and-mods.md @@ -41,18 +41,37 @@ copy a single client file without its README and conformance version. ## Integration lifecycle +The apply-then-report steps below require the created Session to request +`outcome-reporting-v1`; otherwise the runtime intentionally preserves legacy +Commit and replay behavior. + 1. Capture a bounded game-owned event and call `observe`. 2. Give Rin only candidate actions the game can safely implement. 3. Use the asynchronous Proposal Job API from real-time games. 4. Validate the returned action ID and payload against a local allowlist. 5. Marshal to the engine's owning thread and apply the action. -6. Call `commit` with the actual outcome, including a rejection when needed. -7. Keep an authored or deterministic fallback when Rin is unavailable. +6. Persist the actual result in the game's Outcome Outbox as part of the apply + transaction. +7. Call `commit` from the Outbox, including a rejection when needed. Retry a + failed report without applying the action again. +8. Keep an authored or deterministic fallback when Rin is unavailable. + +Treat an ambiguous Proposal submit, poll, timeout, or cancellation as +outcome-unknown and fail closed. Retry the same identity; do not execute the +fallback unless the integration has confirmed that no online Proposal exists. +Persist the complete Propose request and operation identity before submission, +then persist the Job ID immediately after `202`. Resume that record before any +new turn or fallback. Clear it only in the same authoritative transaction that +stores the game result, applied marker, and Outcome Outbox entry. Never call online proposal or generation endpoints from a render/update loop. One player interaction may start one job; ordinary frames should only poll a local future, coroutine, timer, or main-thread queue. +Commit records an outcome rather than authorizing execution. See +[action outcome reporting](outcome-reporting.md) for Outbox, late-outcome, +same-`request_id` retry, and offline reconciliation rules. + ## Credentials and transport - Keep model-provider credentials in the Rin sidecar only. diff --git a/docs/sdk-and-mods.zh-CN.md b/docs/sdk-and-mods.zh-CN.md index 4c75aef..437f77c 100644 --- a/docs/sdk-and-mods.zh-CN.md +++ b/docs/sdk-and-mods.zh-CN.md @@ -39,18 +39,31 @@ SDK 当前以源码为主,尚未发布到语言注册表。应固定到带 Tag ## 接入生命周期 +以下“先应用、再回报”步骤要求创建 Session 时请求 +`outcome-reporting-v1`;否则 Runtime 会有意保留旧版 Commit 与重放行为。 + 1. 捕获一个有界、由游戏拥有的事件并调用 `observe`。 2. 只向 Rin 提供游戏能够安全实现的候选动作。 3. 实时游戏使用异步 Proposal Job API。 4. 用本地白名单验证返回的 Action ID 和 Payload。 5. 切回引擎拥有的线程并应用动作。 -6. 用实际结果调用 `commit`,必要时提交拒绝。 -7. Rin 不可用时保留 authored 或 deterministic fallback。 +6. 在应用事务中把实际结果写入游戏自己的 Outcome Outbox。 +7. 从 Outbox 调用 `commit`,必要时回报拒绝;失败只重报,不重复应用动作。 +8. Rin 不可用时保留 authored 或 deterministic fallback。 + +Proposal 提交、轮询、超时或取消若结果不确定,应标为 outcome-unknown 并 +fail closed;使用相同身份恢复,确认不存在在线 Proposal 前不得执行 fallback。 +提交前应持久化完整 Propose Request 与 Operation 身份,并在 `202` 后立即 +保存 Job ID。任何新 Turn 或 Fallback 之前都要先恢复这条记录;只有游戏结果、 +Applied Marker 与 Outcome Outbox 在同一个权威事务中落盘时才能清除。 不要从渲染或 Update 循环调用在线 Proposal 或 Generation 端点。一次玩家 交互最多启动一个 Job;普通帧只应检查本地 Future、Coroutine、Timer 或 主线程队列。 +Commit 是结果记账而不是执行授权。Outbox、延迟结果、相同 `request_id` 重试 +和离线对账规则见[动作结果记账](outcome-reporting.zh-CN.md)。 + ## 凭据与传输 - 模型供应商凭据只保留在 Rin Sidecar。 diff --git a/examples/basic/main.go b/examples/basic/main.go index 5c87ad1..c6f8bab 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "crypto/sha256" "encoding/json" "errors" "flag" @@ -9,6 +10,11 @@ import ( "io" "net/http" "os" + "path/filepath" + "reflect" + "sort" + "strconv" + "strings" "time" "github.com/sunrioa/rin/protocol" @@ -26,57 +32,1763 @@ type envelope struct { Error *protocol.ErrorDetail `json:"error"` } +type appliedOutcome struct { + accepted bool + outcome string +} + +// appliedMarker is game-owned proof that one operation already affected the +// authoritative world. ProposalID and OccurrenceTick are stored independently +// from the Outbox so a valid-looking replacement Commit cannot authenticate +// itself during recovery. +type appliedMarker struct { + outcome appliedOutcome + proposalID string + occurrenceTick int64 +} + +type pendingReport struct { + kind string + commit protocol.CommitRequest + observe protocol.ObserveRequest + fallback protocol.ObserveRequest +} + +// proposalAttempt is persisted before the first Propose POST. Until the +// authoritative effect and its report are durably recorded together, this +// exact request remains the only turn the example is allowed to resume. +type proposalAttempt struct { + OperationID string + Sequence uint64 + Request protocol.ProposeRequest + Fallback protocol.ActionProposal + Submitted bool +} + +type gameTransaction struct { + rollbacks []func() +} + +func (tx *gameTransaction) onRollback(rollback func()) { + if rollback != nil { + tx.rollbacks = append(tx.rollbacks, rollback) + } +} + +func (tx *gameTransaction) rollback() { + for index := len(tx.rollbacks) - 1; index >= 0; index-- { + tx.rollbacks[index]() + } +} + +// gameOutcomeStore is the smallest useful game-side outcome state machine: +// applied prevents a repeated operation from touching game state twice, while +// pending retains the exact Commit or Observe until Rin acknowledges it. +type gameOutcomeStore struct { + runID string + create protocol.CreateSessionRequest + operationSequence uint64 + lastAuthoritativeTick int64 + proposalAttempt *proposalAttempt + applied map[string]appliedMarker + pending map[string]pendingReport + authoritativeTransaction func(func(*gameTransaction) error) error + persistReportConversion func(string, pendingReport) error + persistReportAck func(string) error + currentTick func() int64 + applyEffect func(*gameTransaction, protocol.ActionSpec) + durabilityBlocked error +} + +const gameOutcomeStateVersion = 3 +const exampleRunIDLayout = "20060102T150405.000000000" + +type persistedAppliedOutcome struct { + Accepted bool `json:"accepted"` + Outcome string `json:"outcome"` + ProposalID string `json:"proposal_id"` + OccurrenceTick int64 `json:"occurrence_tick"` +} + +type persistedPendingReport struct { + Kind string `json:"kind"` + Commit protocol.CommitRequest `json:"commit,omitempty"` + Observe protocol.ObserveRequest `json:"observe,omitempty"` + Fallback protocol.ObserveRequest `json:"fallback,omitempty"` +} + +type persistedProposalAttempt struct { + OperationID string `json:"operation_id"` + Sequence uint64 `json:"sequence"` + Request protocol.ProposeRequest `json:"request"` + Fallback protocol.ActionProposal `json:"fallback"` + Submitted bool `json:"submitted"` +} + +type persistedGameOutcomeState struct { + Version int `json:"version"` + RunID string `json:"run_id"` + Create protocol.CreateSessionRequest `json:"create"` + OperationSequence uint64 `json:"operation_sequence"` + LastAuthoritativeTick int64 `json:"last_authoritative_tick"` + ProposalAttempt *persistedProposalAttempt `json:"proposal_attempt,omitempty"` + Applied map[string]persistedAppliedOutcome `json:"applied"` + Pending map[string]persistedPendingReport `json:"pending"` +} + func main() { address := flag.String("url", "http://127.0.0.1:7374", "Rin base URL") + statePath := flag.String( + "state", + defaultGameOutcomeStatePath(), + "durable game-side marker and outcome Outbox file", + ) flag.Parse() c := client{baseURL: *address, token: os.Getenv("RIN_TOKEN"), http: &http.Client{Timeout: 5 * time.Second}} - suffix := time.Now().UTC().Format("20060102T150405.000000000") - sessionID := "example." + suffix + game, err := newDurableGameOutcomeStore(*statePath) + must(err) + must(game.runExampleInvocation(&c)) +} + +func newGameOutcomeStore() *gameOutcomeStore { + store := &gameOutcomeStore{ + applied: make(map[string]appliedMarker), + pending: make(map[string]pendingReport), + currentTick: func() int64 { + return 0 + }, + applyEffect: applyGameEffect, + persistReportConversion: func(string, pendingReport) error { + // PRODUCTION PERSISTENCE HOOK: atomically replace the Commit with + // its pre-persisted Observe fallback before updating this cache. + return nil + }, + persistReportAck: func(string) error { + // PRODUCTION PERSISTENCE HOOK: durably delete the Outbox row. Only + // after this succeeds may the in-memory cache evict the report. + return nil + }, + } + store.authoritativeTransaction = runInMemoryGameTransaction + return store +} - create := protocol.CreateSessionRequest{ +func defaultGameOutcomeStatePath() string { + configDir, err := os.UserConfigDir() + if err != nil || configDir == "" { + return "rin-basic-example-state.json" + } + return filepath.Join(configDir, "rin", "basic-example-state.json") +} + +func newExampleRun(now time.Time) (string, protocol.CreateSessionRequest) { + runID := now.UTC().Format(exampleRunIDLayout) + return runID, exampleCreateRequest(runID) +} + +func exampleCreateRequest(runID string) protocol.CreateSessionRequest { + sessionID := "example." + runID + return protocol.CreateSessionRequest{ ProtocolVersion: protocol.Version, - RequestID: "create." + suffix, + RequestID: "create." + runID, SessionID: sessionID, - Binding: protocol.Binding{GameID: "rin-example", ContentID: "base", ContentVersion: "1.0.0", ContentHash: "example-hash"}, - Seed: 42, + Binding: protocol.Binding{ + GameID: "rin-example", ContentID: "base", + ContentVersion: "1.0.0", ContentHash: "example-hash", + }, + Seed: 42, + Features: []string{protocol.FeatureOutcomeReporting}, Actors: []protocol.ActorSeed{{ - ID: "npc.mira", Kind: "npc", DisplayName: "Mira", Traits: []string{"curious", "careful"}, ThinkEveryTicks: 5, Enabled: true, - Boundaries: []protocol.Boundary{{ID: "boundary.privacy", Description: "Do not reveal private letters.", TriggerTags: []string{"private"}, Response: "refuse"}}, - Goals: []protocol.Goal{{ID: "goal.connect", Description: "Build trust through specific actions.", Priority: 4, PreferredActions: []string{"talk"}, TargetProgress: 3, Status: "active"}}, + ID: "npc.mira", Kind: "npc", DisplayName: "Mira", + Traits: []string{"curious", "careful"}, ThinkEveryTicks: 5, Enabled: true, + Boundaries: []protocol.Boundary{{ + ID: "boundary.privacy", Description: "Do not reveal private letters.", + TriggerTags: []string{"private"}, Response: "refuse", + }}, + Goals: []protocol.Goal{{ + ID: "goal.connect", Description: "Build trust through specific actions.", + Priority: 4, PreferredActions: []string{"talk"}, + TargetProgress: 3, Status: "active", + }}, }}, } - must(c.post("/v1/session/create", create, &protocol.MutationResult{})) +} - observe := protocol.ObserveRequest{ - ProtocolVersion: protocol.Version, SessionID: sessionID, RequestID: "observe." + suffix, EventID: "event.player-waited", Tick: 1, - ObserverIDs: []string{"npc.mira"}, Source: "game", Kind: "dialogue", Summary: "The player waited instead of demanding an answer.", - Quote: "Take your time.", Tags: []string{"conversation", "trust"}, Importance: 4, +func newDurableGameOutcomeStore(path string) (*gameOutcomeStore, error) { + if path == "" { + return nil, errors.New("durable game state path is empty") + } + state, exists, err := loadGameOutcomeState(path) + if err != nil { + return nil, fmt.Errorf("restore durable game state: %w", err) + } + store := newGameOutcomeStore() + if exists { + if err := store.restore(state); err != nil { + return nil, fmt.Errorf("restore durable game state: %w", err) + } + } else { + store.runID, store.create = newExampleRun(time.Now()) + if err := persistGameOutcomeState(path, store.snapshot()); err != nil { + // Fail closed before making a new request if the stable identity and + // Outbox cannot be made durable. + return nil, fmt.Errorf("initialize durable game state: %w", err) + } } - must(c.post("/v1/session/observe", observe, &protocol.MutationResult{})) - propose := protocol.ProposeRequest{ - ProtocolVersion: protocol.Version, SessionID: sessionID, RequestID: "propose." + suffix, ActorID: "npc.mira", Tick: 2, - Intent: "Choose how to respond to the player.", Tags: []string{"conversation"}, + store.authoritativeTransaction = func(mutate func(*gameTransaction) error) error { + return store.runPersistedMutation(mutate, func() error { + return persistGameOutcomeState(path, store.snapshot()) + }) + } + store.persistReportConversion = func( + operationID string, + replacement pendingReport, + ) error { + state := store.snapshot() + state.Pending[operationID] = persistPendingReport(replacement) + return persistGameOutcomeState(path, state) + } + store.persistReportAck = func(operationID string) error { + state := store.snapshot() + delete(state.Pending, operationID) + return persistGameOutcomeState(path, state) + } + return store, nil +} + +func (s *gameOutcomeStore) snapshot() persistedGameOutcomeState { + state := persistedGameOutcomeState{ + Version: gameOutcomeStateVersion, + RunID: s.runID, + Create: s.create, + OperationSequence: s.operationSequence, + LastAuthoritativeTick: s.lastAuthoritativeTick, + Applied: make(map[string]persistedAppliedOutcome, len(s.applied)), + Pending: make(map[string]persistedPendingReport, len(s.pending)), + } + if s.proposalAttempt != nil { + state.ProposalAttempt = &persistedProposalAttempt{ + OperationID: s.proposalAttempt.OperationID, + Sequence: s.proposalAttempt.Sequence, + Request: s.proposalAttempt.Request, + Fallback: s.proposalAttempt.Fallback, + Submitted: s.proposalAttempt.Submitted, + } + } + for operationID, marker := range s.applied { + state.Applied[operationID] = persistedAppliedOutcome{ + Accepted: marker.outcome.accepted, + Outcome: marker.outcome.outcome, + ProposalID: marker.proposalID, + OccurrenceTick: marker.occurrenceTick, + } + } + for operationID, report := range s.pending { + state.Pending[operationID] = persistPendingReport(report) + } + return state +} + +func persistPendingReport(report pendingReport) persistedPendingReport { + return persistedPendingReport{ + Kind: report.kind, + Commit: report.commit, + Observe: report.observe, + Fallback: report.fallback, + } +} + +func validatePersistedGameOutcomeState(state persistedGameOutcomeState) error { + if state.Version != gameOutcomeStateVersion { + return fmt.Errorf( + "unsupported state version %d (want %d)", + state.Version, + gameOutcomeStateVersion, + ) + } + if state.Applied == nil || state.Pending == nil { + return errors.New("state is missing applied or pending maps") + } + if state.RunID == "" || + state.Create.SessionID != "example."+state.RunID || + state.Create.RequestID != "create."+state.RunID { + return errors.New("state has a non-canonical run, Session, or Create request identity") + } + parsedRunID, err := time.Parse(exampleRunIDLayout, state.RunID) + if err != nil || parsedRunID.Format(exampleRunIDLayout) != state.RunID { + return errors.New("state run ID is not canonical") + } + if err := protocol.ValidateCreateSession(state.Create); err != nil { + return fmt.Errorf("state contains an invalid Create request: %w", err) + } + if !reflect.DeepEqual(state.Create, exampleCreateRequest(state.RunID)) { + return errors.New("state Create request differs from the canonical persisted payload") + } + hasOutcomeReporting := false + for _, feature := range state.Create.Features { + if feature == protocol.FeatureOutcomeReporting { + hasOutcomeReporting = true + break + } + } + if !hasOutcomeReporting { + return errors.New("state Create request does not enable outcome-reporting-v1") + } + const maxInt64 = uint64(^uint64(0) >> 1) + if state.OperationSequence > maxInt64 || state.LastAuthoritativeTick < 0 { + return errors.New("state contains an invalid operation sequence or tick high-water") + } + + validateOperation := func(operationID string) (uint64, error) { + prefix := "turn." + state.RunID + "." + if !strings.HasPrefix(operationID, prefix) { + return 0, fmt.Errorf("operation %q is not bound to run %q", operationID, state.RunID) + } + suffix := strings.TrimPrefix(operationID, prefix) + sequence, err := strconv.ParseUint(suffix, 10, 64) + if err != nil || sequence == 0 || + strconv.FormatUint(sequence, 10) != suffix || + sequence > state.OperationSequence { + return 0, fmt.Errorf("operation %q has a non-canonical sequence", operationID) + } + return sequence, nil + } + + for operationID, marker := range state.Applied { + if _, err := validateOperation(operationID); err != nil { + return err + } + if marker.Accepted && marker.Outcome == "" { + return fmt.Errorf("accepted applied marker %q has no outcome", operationID) + } + if marker.ProposalID == "" || + marker.OccurrenceTick < 0 || + marker.OccurrenceTick > state.LastAuthoritativeTick { + return fmt.Errorf( + "applied marker %q has an invalid Proposal identity or occurrence tick", + operationID, + ) + } + } + if state.ProposalAttempt != nil { + attempt := state.ProposalAttempt + sequence, err := validateOperation(attempt.OperationID) + if err != nil { + return err + } + if sequence != attempt.Sequence || + sequence != state.OperationSequence || + attempt.Request.SessionID != state.Create.SessionID || + attempt.Request.RequestID != fmt.Sprintf( + "propose.%s.%d", + state.RunID, + attempt.Sequence, + ) { + return errors.New("state Proposal Attempt identity is not canonical") + } + if err := protocol.ValidatePropose(attempt.Request); err != nil { + return fmt.Errorf("state contains an invalid Propose request: %w", err) + } + if !reflect.DeepEqual( + attempt.Request, + exampleProposeRequest( + state.RunID, + state.Create.SessionID, + attempt.Sequence, + attempt.Request.Tick, + ), + ) { + return errors.New("state Propose request differs from its canonical payload") + } + if attempt.Request.Tick > state.LastAuthoritativeTick { + return errors.New("state Proposal Attempt tick exceeds the authoritative high-water") + } + expectedFallback, fallbackErr := authoredFallback(attempt.Request, "wait") + if fallbackErr != nil || + !reflect.DeepEqual(attempt.Fallback, expectedFallback) { + return errors.New("state Proposal Attempt contains an invalid authored fallback") + } + if _, applied := state.Applied[attempt.OperationID]; applied { + return errors.New("state Proposal Attempt already has an applied marker") + } + if len(state.Pending) != 0 { + return errors.New("state cannot contain a Proposal Attempt and an outcome Outbox together") + } + } + + for operationID, report := range state.Pending { + sequence, err := validateOperation(operationID) + if err != nil { + return err + } + marker, ok := state.Applied[operationID] + if !ok { + return fmt.Errorf("pending operation %q has no authoritative applied marker", operationID) + } + switch report.Kind { + case "commit": + if !reflect.DeepEqual(report.Observe, protocol.ObserveRequest{}) { + return fmt.Errorf("pending Commit %q contains an unexpected Observe", operationID) + } + if err := validatePersistedCommitReport( + state, + operationID, + marker, + report, + ); err != nil { + return err + } + case "observe": + if report.Commit.RequestID != "" { + if err := validatePersistedCommitReport( + state, + operationID, + marker, + report, + ); err != nil { + return err + } + if !reflect.DeepEqual(report.Observe, report.Fallback) { + return fmt.Errorf("converted Observe %q does not equal its persisted fallback", operationID) + } + } else { + if !reflect.DeepEqual(report.Commit, protocol.CommitRequest{}) || + !reflect.DeepEqual(report.Fallback, protocol.ObserveRequest{}) { + return fmt.Errorf("pending Observe %q contains unexpected Commit data", operationID) + } + if err := protocol.ValidateObserve(report.Observe); err != nil { + return fmt.Errorf("pending Observe %q is invalid: %w", operationID, err) + } + expectedOutcome := planGameAction(protocol.ActionSpec{ID: "wait"}) + expectedMarker := persistedAppliedOutcome{ + Accepted: expectedOutcome.accepted, + Outcome: expectedOutcome.outcome, + ProposalID: fmt.Sprintf( + "offline.propose.%s.%d.wait", + state.RunID, + sequence, + ), + OccurrenceTick: marker.OccurrenceTick, + } + expectedObserve := protocol.ObserveRequest{ + ProtocolVersion: protocol.Version, + SessionID: state.Create.SessionID, + RequestID: "reconcile." + operationID, + EventID: "fallback." + operationID, + Tick: marker.OccurrenceTick, + ObserverIDs: []string{"npc.mira"}, + Source: "basic-example", + Kind: "fallback_action", + Summary: "Local fallback wait: " + expectedOutcome.outcome, + Tags: []string{"fallback"}, + Importance: 3, + } + if marker != expectedMarker || + !reflect.DeepEqual(report.Observe, expectedObserve) { + return fmt.Errorf("pending Observe %q is not bound to its operation", operationID) + } + } + default: + return fmt.Errorf( + "pending operation %q has unknown report kind %q", + operationID, + report.Kind, + ) + } + } + return nil +} + +func validatePersistedCommitReport( + state persistedGameOutcomeState, + operationID string, + marker persistedAppliedOutcome, + report persistedPendingReport, +) error { + if err := protocol.ValidateCommit(report.Commit); err != nil { + return fmt.Errorf("pending Commit %q is invalid: %w", operationID, err) + } + if err := protocol.ValidateObserve(report.Fallback); err != nil { + return fmt.Errorf("pending Commit fallback %q is invalid: %w", operationID, err) + } + canonicalCommit := protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: state.Create.SessionID, + RequestID: "commit." + operationID, + ProposalID: marker.ProposalID, + EventID: "outcome." + operationID, + Tick: marker.OccurrenceTick, + Accepted: marker.Accepted, + Outcome: marker.Outcome, + Tags: []string{"conversation"}, + } + expected := newCommitReport( + operationID, + "npc.mira", + canonicalCommit, + ).withOccurrenceTick(marker.OccurrenceTick) + if report.Kind == "observe" { + expected.kind = "observe" + expected.observe = expected.fallback + } + if !reflect.DeepEqual(report, persistPendingReport(expected)) { + return fmt.Errorf("pending Commit %q is not bound to its marker and fallback", operationID) + } + return nil +} + +func (s *gameOutcomeStore) restore(state persistedGameOutcomeState) error { + if err := validatePersistedGameOutcomeState(state); err != nil { + return err + } + s.runID = state.RunID + s.create = state.Create + s.operationSequence = state.OperationSequence + s.lastAuthoritativeTick = state.LastAuthoritativeTick + if state.ProposalAttempt != nil { + attempt := state.ProposalAttempt + s.proposalAttempt = &proposalAttempt{ + OperationID: attempt.OperationID, + Sequence: attempt.Sequence, + Request: attempt.Request, + Fallback: attempt.Fallback, + Submitted: attempt.Submitted, + } + } + for operationID, outcome := range state.Applied { + s.applied[operationID] = appliedMarker{ + outcome: appliedOutcome{ + accepted: outcome.Accepted, + outcome: outcome.Outcome, + }, + proposalID: outcome.ProposalID, + occurrenceTick: outcome.OccurrenceTick, + } + } + for operationID, report := range state.Pending { + s.pending[operationID] = pendingReport{ + kind: report.Kind, + commit: report.Commit, + observe: report.Observe, + fallback: report.Fallback, + } + } + return nil +} + +func loadGameOutcomeState(path string) (persistedGameOutcomeState, bool, error) { + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return persistedGameOutcomeState{}, false, nil + } + if err != nil { + return persistedGameOutcomeState{}, false, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return persistedGameOutcomeState{}, false, err + } + if info.Size() > 2<<20 { + return persistedGameOutcomeState{}, false, fmt.Errorf( + "state file is %d bytes; limit is %d", + info.Size(), + 2<<20, + ) + } + decoder := json.NewDecoder(io.LimitReader(file, (2<<20)+1)) + decoder.DisallowUnknownFields() + var state persistedGameOutcomeState + if err := decoder.Decode(&state); err != nil { + return persistedGameOutcomeState{}, false, err + } + var trailing json.RawMessage + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return persistedGameOutcomeState{}, false, errors.New( + "state file contains multiple JSON values", + ) + } + return persistedGameOutcomeState{}, false, err + } + return state, true, nil +} + +func persistGameOutcomeState(path string, state persistedGameOutcomeState) error { + return persistGameOutcomeStateWithDirectorySync(path, state, syncStateDirectory) +} + +func persistGameOutcomeStateWithDirectorySync( + path string, + state persistedGameOutcomeState, + syncDirectory func(string) error, +) error { + if syncDirectory == nil { + return errors.New("state directory sync function is nil") + } + payload, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + payload = append(payload, '\n') + statePath, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + return err + } + directory := filepath.Dir(statePath) + syncPlan, err := loadOrCreateStateDirectorySyncPlan( + statePath, + directory, + syncDirectory, + ) + if err != nil { + return err + } + if err := os.MkdirAll(directory, 0o700); err != nil { + return err + } + if syncPlan != nil { + // The durable journal survives failed cleanup and process reconstruction. + // It is cleared only after every original parent has acknowledged its + // newly created child entry. + for _, parent := range syncPlan.parents { + if err := syncDirectory(parent); err != nil { + syncErr := fmt.Errorf( + "sync state directory parent %q after MkdirAll: %w", + parent, + err, + ) + if cleanupErr := removeCreatedStateDirectories( + syncPlan.createdDirectories, + ); cleanupErr != nil { + return errors.Join( + syncErr, + fmt.Errorf( + "clean up newly created state directories after parent sync failure: %w", + cleanupErr, + ), + ) + } + return syncErr + } + } + if err := clearStateDirectorySyncJournal(syncPlan, syncDirectory); err != nil { + return err + } + } + file, err := os.CreateTemp(directory, "."+filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tempPath := file.Name() + defer file.Close() + defer os.Remove(tempPath) + if err := file.Chmod(0o600); err != nil { + return err + } + if _, err := file.Write(payload); err != nil { + return err + } + if err := file.Sync(); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + if err := os.Rename(tempPath, statePath); err != nil { + return err + } + if err := syncDirectory(directory); err != nil { + return &stateFileReplacedError{err: err} + } + return nil +} + +const stateDirectorySyncJournalVersion = 1 +const maxStateDirectorySyncJournalBytes = 64 << 10 +const maxStateDirectorySyncJournalParents = 256 + +type persistedStateDirectorySyncJournal struct { + Version int `json:"version"` + StatePath string `json:"state_path"` + Parents []string `json:"parents"` +} + +type stateDirectorySyncPlan struct { + journalPath string + journalDirectory string + parents []string + createdDirectories []string +} + +func loadOrCreateStateDirectorySyncPlan( + statePath string, + directory string, + syncDirectory func(string) error, +) (*stateDirectorySyncPlan, error) { + journalPath, exists, err := findStateDirectorySyncJournal(statePath, directory) + if err != nil { + return nil, err + } + if exists { + plan, err := loadStateDirectorySyncJournal( + statePath, + directory, + journalPath, + ) + if err != nil { + return nil, err + } + // A prior call may have failed while syncing the journal's own directory. + // Re-confirm the on-disk todo before it is allowed to authorize MkdirAll. + if err := syncDirectory(plan.journalDirectory); err != nil { + return nil, fmt.Errorf( + "confirm recovered state directory sync journal in %q: %w", + plan.journalDirectory, + err, + ) + } + return plan, nil + } + parents, createdDirectories, err := stateDirectoryCreationPlan(directory) + if err != nil { + return nil, err + } + if len(parents) == 0 { + return nil, nil + } + journalDirectory := parents[len(parents)-1] + journalPath = filepath.Join( + journalDirectory, + stateDirectorySyncJournalName(statePath), + ) + journal := persistedStateDirectorySyncJournal{ + Version: stateDirectorySyncJournalVersion, + StatePath: statePath, + Parents: parents, + } + if err := createStateDirectorySyncJournal( + journalPath, + journalDirectory, + journal, + syncDirectory, + ); err != nil { + return nil, err + } + return &stateDirectorySyncPlan{ + journalPath: journalPath, + journalDirectory: journalDirectory, + parents: parents, + createdDirectories: createdDirectories, + }, nil +} + +// stateDirectoryCreationPlan describes exactly the directory entries MkdirAll +// must add. Both slices are ordered deepest to shallowest. +func stateDirectoryCreationPlan(directory string) ([]string, []string, error) { + current := filepath.Clean(directory) + var parents []string + var directories []string + for { + info, err := os.Stat(current) + if err == nil { + if !info.IsDir() { + return nil, nil, fmt.Errorf( + "state directory path %q is not a directory", + current, + ) + } + return parents, directories, nil + } + if !errors.Is(err, os.ErrNotExist) { + return nil, nil, fmt.Errorf("inspect state directory %q: %w", current, err) + } + parent := filepath.Dir(current) + if parent == current { + return nil, nil, fmt.Errorf( + "state directory %q has no existing ancestor", + directory, + ) + } + directories = append(directories, current) + parents = append(parents, parent) + current = parent + } +} + +func stateDirectorySyncJournalName(statePath string) string { + sum := sha256.Sum256([]byte(statePath)) + return fmt.Sprintf(".rin-basic-dir-sync-%x.json", sum) +} + +func findStateDirectorySyncJournal( + statePath string, + directory string, +) (string, bool, error) { + name := stateDirectorySyncJournalName(statePath) + current := filepath.Clean(directory) + found := "" + for depth := 0; ; depth++ { + if depth > maxStateDirectorySyncJournalParents { + return "", false, errors.New("state directory journal search exceeds depth limit") + } + candidate := filepath.Join(current, name) + info, err := os.Lstat(candidate) + switch { + case err == nil: + if !info.Mode().IsRegular() { + return "", false, fmt.Errorf( + "state directory sync journal %q is not a regular file", + candidate, + ) + } + if found != "" { + return "", false, errors.New( + "multiple state directory sync journals match one state path", + ) + } + found = candidate + case !errors.Is(err, os.ErrNotExist): + return "", false, fmt.Errorf( + "inspect state directory sync journal %q: %w", + candidate, + err, + ) + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return found, found != "", nil +} + +func createStateDirectorySyncJournal( + journalPath string, + journalDirectory string, + journal persistedStateDirectorySyncJournal, + syncDirectory func(string) error, +) error { + payload, err := json.Marshal(journal) + if err != nil { + return fmt.Errorf("encode state directory sync journal: %w", err) + } + payload = append(payload, '\n') + if len(payload) > maxStateDirectorySyncJournalBytes { + return fmt.Errorf( + "state directory sync journal is %d bytes; limit is %d", + len(payload), + maxStateDirectorySyncJournalBytes, + ) + } + file, err := os.OpenFile( + journalPath, + os.O_WRONLY|os.O_CREATE|os.O_EXCL, + 0o600, + ) + if err != nil { + return fmt.Errorf("create state directory sync journal: %w", err) + } + if _, err := file.Write(payload); err != nil { + _ = file.Close() + return fmt.Errorf("write state directory sync journal: %w", err) + } + if err := file.Sync(); err != nil { + _ = file.Close() + return fmt.Errorf("sync state directory sync journal: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close state directory sync journal: %w", err) + } + if err := syncDirectory(journalDirectory); err != nil { + return fmt.Errorf( + "confirm state directory sync journal in %q: %w", + journalDirectory, + err, + ) + } + return nil +} + +func loadStateDirectorySyncJournal( + statePath string, + directory string, + journalPath string, +) (*stateDirectorySyncPlan, error) { + file, err := os.Open(journalPath) + if err != nil { + return nil, fmt.Errorf("open state directory sync journal: %w", err) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, fmt.Errorf("stat state directory sync journal: %w", err) + } + if !info.Mode().IsRegular() || + info.Size() <= 0 || + info.Size() > maxStateDirectorySyncJournalBytes { + return nil, fmt.Errorf( + "state directory sync journal has invalid type or size %d", + info.Size(), + ) + } + decoder := json.NewDecoder(io.LimitReader( + file, + maxStateDirectorySyncJournalBytes+1, + )) + decoder.DisallowUnknownFields() + var journal persistedStateDirectorySyncJournal + if err := decoder.Decode(&journal); err != nil { + return nil, fmt.Errorf("decode state directory sync journal: %w", err) + } + var trailing json.RawMessage + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return nil, errors.New( + "state directory sync journal contains multiple JSON values", + ) + } + return nil, fmt.Errorf("decode state directory sync journal trailer: %w", err) + } + journalDirectory := filepath.Dir(journalPath) + if err := validateStateDirectorySyncJournal( + statePath, + directory, + journalDirectory, + journal, + ); err != nil { + return nil, err + } + createdDirectories := make([]string, 0, len(journal.Parents)) + createdDirectories = append(createdDirectories, directory) + createdDirectories = append( + createdDirectories, + journal.Parents[:len(journal.Parents)-1]..., + ) + return &stateDirectorySyncPlan{ + journalPath: journalPath, + journalDirectory: journalDirectory, + parents: append([]string(nil), journal.Parents...), + createdDirectories: createdDirectories, + }, nil +} + +func validateStateDirectorySyncJournal( + statePath string, + directory string, + journalDirectory string, + journal persistedStateDirectorySyncJournal, +) error { + if journal.Version != stateDirectorySyncJournalVersion || + journal.StatePath != statePath { + return errors.New( + "state directory sync journal has an invalid version or state-path binding", + ) + } + if len(journal.Parents) == 0 || + len(journal.Parents) > maxStateDirectorySyncJournalParents { + return errors.New("state directory sync journal parent count is out of bounds") + } + expected := filepath.Dir(directory) + for index, parent := range journal.Parents { + if parent == "" || + !filepath.IsAbs(parent) || + filepath.Clean(parent) != parent || + parent != expected { + return fmt.Errorf( + "state directory sync journal parent %d is not the expected ancestor", + index, + ) + } + expected = filepath.Dir(parent) + if expected == parent && index != len(journal.Parents)-1 { + return errors.New("state directory sync journal extends beyond the filesystem root") + } + } + if journal.Parents[len(journal.Parents)-1] != journalDirectory { + return errors.New( + "state directory sync journal is not stored at its bound existing ancestor", + ) + } + return nil +} + +func clearStateDirectorySyncJournal( + plan *stateDirectorySyncPlan, + syncDirectory func(string) error, +) error { + if err := os.Remove(plan.journalPath); err != nil { + return fmt.Errorf("remove completed state directory sync journal: %w", err) + } + if err := syncDirectory(plan.journalDirectory); err != nil { + return fmt.Errorf( + "confirm completed state directory sync journal removal in %q: %w", + plan.journalDirectory, + err, + ) + } + return nil +} + +func removeCreatedStateDirectories(directories []string) error { + var cleanupErrors []error + for _, directory := range directories { + if err := os.Remove(directory); err != nil && + !errors.Is(err, os.ErrNotExist) { + cleanupErrors = append( + cleanupErrors, + fmt.Errorf("remove newly created state directory %q: %w", directory, err), + ) + } + } + return errors.Join(cleanupErrors...) +} + +type stateFileReplacedError struct { + err error +} + +func (e *stateFileReplacedError) Error() string { + return "state file was replaced but parent-directory sync failed: " + e.err.Error() +} + +func (e *stateFileReplacedError) Unwrap() error { + return e.err +} + +func stateFileWasReplaced(err error) bool { + var replaced *stateFileReplacedError + return errors.As(err, &replaced) +} + +func syncStateDirectory(path string) error { + directory, err := os.Open(path) + if err != nil { + return err + } + syncErr := directory.Sync() + closeErr := directory.Close() + if syncErr != nil { + return syncErr + } + if closeErr != nil { + return closeErr + } + return nil +} + +func (s *gameOutcomeStore) applyAndEnqueue( + operationID string, + outcome appliedOutcome, + report pendingReport, + applyGameState func(*gameTransaction) error, +) (appliedOutcome, error) { + return s.applyAndEnqueueAttempt( + operationID, + outcome, + report, + nil, + 0, + applyGameState, + ) +} + +func (s *gameOutcomeStore) applyAndEnqueueAttempt( + operationID string, + outcome appliedOutcome, + report pendingReport, + completedAttempt *proposalAttempt, + proposalTick int64, + applyGameState func(*gameTransaction) error, +) (appliedOutcome, error) { + if err := s.checkDurability(); err != nil { + return appliedOutcome{}, err + } + if marker, ok := s.applied[operationID]; ok { + return marker.outcome, nil + } + if completedAttempt != nil && s.proposalAttempt != completedAttempt { + return appliedOutcome{}, errors.New("Proposal Attempt changed before authoritative completion") + } + err := s.authoritativeTransaction(func(tx *gameTransaction) error { + occurrenceTick := s.currentTick() + if occurrenceTick < s.lastAuthoritativeTick { + occurrenceTick = s.lastAuthoritativeTick + } + if completedAttempt != nil && occurrenceTick < completedAttempt.Request.Tick { + occurrenceTick = completedAttempt.Request.Tick + } + if occurrenceTick < proposalTick { + occurrenceTick = proposalTick + } + if occurrenceTick < 0 { + return errors.New("authoritative occurrence tick is negative") + } + report = report.withOccurrenceTick(occurrenceTick) + marker := appliedMarker{ + outcome: outcome, + occurrenceTick: occurrenceTick, + } + switch report.kind { + case "commit": + marker.proposalID = report.commit.ProposalID + case "observe": + if completedAttempt != nil { + marker.proposalID = completedAttempt.Fallback.ID + } + } + // PRODUCTION PERSISTENCE HOOK: the game effect, applied marker, complete + // report (including its safe fallback), Proposal Attempt deletion, and + // authoritative tick high-water share one durable game transaction. + previousTick := s.lastAuthoritativeTick + previousAttempt := s.proposalAttempt + tx.onRollback(func() { + delete(s.applied, operationID) + delete(s.pending, operationID) + s.lastAuthoritativeTick = previousTick + s.proposalAttempt = previousAttempt + }) + s.lastAuthoritativeTick = occurrenceTick + s.applied[operationID] = marker + s.pending[operationID] = report + if completedAttempt != nil { + s.proposalAttempt = nil + } + return applyGameState(tx) + }) + if err != nil { + return appliedOutcome{}, err + } + return outcome, nil +} + +func (s *gameOutcomeStore) retainProposalAttempt() (*proposalAttempt, error) { + if err := s.checkDurability(); err != nil { + return nil, err + } + if s.proposalAttempt != nil { + return s.proposalAttempt, nil + } + const maxInt64 = int64(^uint64(0) >> 1) + if s.operationSequence >= uint64(maxInt64) || + s.lastAuthoritativeTick == maxInt64 { + return nil, errors.New("authoritative operation clock overflow") + } + nextSequence := s.operationSequence + 1 + tick := int64(nextSequence) + if tick <= s.lastAuthoritativeTick { + tick = s.lastAuthoritativeTick + 1 + } + if current := s.currentTick(); current > tick { + tick = current + } + if tick < 0 { + return nil, errors.New("authoritative operation clock is negative") + } + request := exampleProposeRequest( + s.runID, + s.create.SessionID, + nextSequence, + tick, + ) + fallback, err := authoredFallback(request, "wait") + if err != nil { + return nil, err + } + attempt := &proposalAttempt{ + OperationID: fmt.Sprintf("turn.%s.%d", s.runID, nextSequence), + Sequence: nextSequence, + Request: request, + Fallback: fallback, + } + err = s.authoritativeTransaction(func(tx *gameTransaction) error { + oldSequence := s.operationSequence + oldTick := s.lastAuthoritativeTick + tx.onRollback(func() { + s.operationSequence = oldSequence + s.lastAuthoritativeTick = oldTick + s.proposalAttempt = nil + }) + s.operationSequence = nextSequence + s.lastAuthoritativeTick = tick + s.proposalAttempt = attempt + return nil + }) + if err != nil { + return nil, err + } + return attempt, nil +} + +func exampleProposeRequest( + runID string, + sessionID string, + sequence uint64, + tick int64, +) protocol.ProposeRequest { + return protocol.ProposeRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: fmt.Sprintf("propose.%s.%d", runID, sequence), + ActorID: "npc.mira", + Tick: tick, + Intent: "Choose how to respond to the player.", + Tags: []string{"conversation"}, CandidateActions: []protocol.ActionSpec{ {ID: "talk", Kind: "dialogue", Description: "ask one honest question"}, {ID: "refuse", Kind: "refuse", Description: "protect a private boundary"}, {ID: "wait", Kind: "wait", Description: "stay silent for now"}, }, } +} + +func (s *gameOutcomeStore) markProposalSubmitted(attempt *proposalAttempt) error { + if err := s.checkDurability(); err != nil { + return err + } + if s.proposalAttempt != attempt { + return errors.New("Proposal Attempt changed before submit") + } + if attempt.Submitted { + return nil + } + return s.authoritativeTransaction(func(tx *gameTransaction) error { + tx.onRollback(func() { + attempt.Submitted = false + }) + attempt.Submitted = true + return nil + }) +} + +func (s *gameOutcomeStore) flush(c *client) error { + if err := s.checkDurability(); err != nil { + return err + } + operationIDs := make([]string, 0, len(s.pending)) + for operationID := range s.pending { + operationIDs = append(operationIDs, operationID) + } + sort.Strings(operationIDs) + for _, operationID := range operationIDs { + report := s.pending[operationID] + reported := false + if report.kind == "commit" { + err := c.post("/v1/action/commit", report.commit, &protocol.MutationResult{}) + if err != nil { + if !isIrrecoverableCommitError(err) { + // A timeout or temporary server error might mean the Commit + // succeeded. Retain and retry its exact request ID. + return err + } + converted := report + converted.kind = "observe" + converted.observe = report.fallback + if persistErr := s.persistReportConversion(operationID, converted); persistErr != nil { + if stateFileWasReplaced(persistErr) { + s.pending[operationID] = converted + s.blockDurability(persistErr) + } + return fmt.Errorf("persist Commit-to-Observe conversion: %w", persistErr) + } + s.pending[operationID] = converted + report = converted + } else { + reported = true + } + } + if !reported && report.kind == "observe" { + if err := c.post("/v1/session/observe", report.observe, &protocol.MutationResult{}); err != nil { + return err + } + reported = true + } + if !reported { + return fmt.Errorf("unknown pending report kind %q", report.kind) + } + if err := s.persistReportAck(operationID); err != nil { + if stateFileWasReplaced(err) { + delete(s.pending, operationID) + s.blockDurability(err) + } + return fmt.Errorf("persist report acknowledgement: %w", err) + } + delete(s.pending, operationID) + } + return nil +} + +func planGameAction(action protocol.ActionSpec) appliedOutcome { + switch action.ID { + case "talk": + return appliedOutcome{accepted: true, outcome: "Mira asked what the player wanted remembered."} + case "refuse": + return appliedOutcome{accepted: true, outcome: "Mira protected the private boundary."} + case "wait": + return appliedOutcome{accepted: true, outcome: "Mira stayed silent for now."} + default: + return appliedOutcome{outcome: "The game rejected an action outside its local allowlist."} + } +} + +func applyGameEffect(tx *gameTransaction, action protocol.ActionSpec) { + // Replace with the actual game-state mutation enlisted in + // authoritativeTransaction. Register its inverse before mutating so errors + // and panics roll the effect back with the marker and Outbox. + tx.onRollback(func() { + fmt.Printf("roll back game-owned action: %s\n", action.ID) + }) + fmt.Printf("apply game-owned action: %s\n", action.ID) +} + +func runInMemoryGameTransaction(mutate func(*gameTransaction) error) (err error) { + tx := &gameTransaction{} + defer func() { + if recovered := recover(); recovered != nil { + tx.rollback() + err = fmt.Errorf("authoritative game transaction panicked: %v", recovered) + } else if err != nil { + tx.rollback() + } + }() + err = mutate(tx) + return err +} + +func runPersistedGameTransaction( + mutate func(*gameTransaction) error, + persist func() error, +) error { + var committedErr error + err := runInMemoryGameTransaction(func(tx *gameTransaction) error { + if err := mutate(tx); err != nil { + return err + } + if err := persist(); err != nil { + if stateFileWasReplaced(err) { + // Rename made the new state visible. Keep memory aligned with + // disk, but surface that directory durability was not confirmed. + committedErr = err + return nil + } + return fmt.Errorf("persist authoritative game transaction: %w", err) + } + return nil + }) + if err != nil { + return err + } + return committedErr +} + +func (s *gameOutcomeStore) runPersistedMutation( + mutate func(*gameTransaction) error, + persist func() error, +) error { + if err := s.checkDurability(); err != nil { + return err + } + err := runPersistedGameTransaction(mutate, persist) + if stateFileWasReplaced(err) { + s.blockDurability(err) + } + return err +} + +func (s *gameOutcomeStore) blockDurability(err error) { + if s.durabilityBlocked == nil { + s.durabilityBlocked = err + } +} + +func (s *gameOutcomeStore) checkDurability() error { + if s.durabilityBlocked == nil { + return nil + } + return fmt.Errorf( + "durability_unconfirmed: restart and restore the replaced state before continuing: %w", + s.durabilityBlocked, + ) +} + +func (r pendingReport) withOccurrenceTick(tick int64) pendingReport { + if r.kind == "commit" { + r.commit.Tick = tick + r.fallback.Tick = tick + } else { + r.observe.Tick = tick + } + return r +} + +func newCommitReport( + operationID string, + observerID string, + commit protocol.CommitRequest, +) pendingReport { + return pendingReport{ + kind: "commit", + commit: commit, + // This degradation payload is persisted with the Commit at the same + // occurrence. It records only episodic memory: no inferred goals, + // recent actions, scheduler changes, or relative facts. + fallback: protocol.ObserveRequest{ + ProtocolVersion: protocol.Version, + SessionID: commit.SessionID, + RequestID: "reconcile." + operationID, + EventID: commit.EventID, + ObserverIDs: []string{observerID}, + Source: "basic-example", + Kind: "action_outcome", + Summary: "Authoritative outcome: " + commit.Outcome, + Tags: append([]string{"outcome-report"}, commit.Tags...), + Importance: 3, + }, + } +} + +func (s *gameOutcomeStore) runExampleInvocation(c *client) error { + if err := s.checkDurability(); err != nil { + return err + } + hadPendingReports := len(s.pending) != 0 + + // Create is itself idempotent and is always retried from the exact persisted + // payload before any prior Outbox entry is sent. + createErr := retrySameRequest(2, func() error { + return c.post("/v1/session/create", s.create, &protocol.MutationResult{}) + }) + if createErr != nil { + if hadPendingReports { + return fmt.Errorf("stable Create unavailable; retained Outbox was not drained: %w", createErr) + } + if s.proposalAttempt != nil && s.proposalAttempt.Submitted { + return fmt.Errorf( + "proposal_outcome_unknown: stable Create unavailable while an exact Proposal Attempt is retained: %w", + createErr, + ) + } + if !createFailureAllowsOfflineFallback(createErr) { + return fmt.Errorf( + "stable Create failed closed; no fallback report was generated: %w", + createErr, + ) + } + attempt, err := s.retainProposalAttempt() + if err != nil { + return err + } + return s.completeColdFallback(attempt) + } + + if err := s.flush(c); err != nil { + return fmt.Errorf("drain restored authoritative Outbox: %w", err) + } + if hadPendingReports && s.proposalAttempt == nil { + // This invocation performed recovery work; it deliberately does not + // start an unrelated new turn in the same process. + return nil + } + + attempt := s.proposalAttempt + if attempt == nil { + var err error + attempt, err = s.retainProposalAttempt() + if err != nil { + return err + } + } + if err := s.resolveProposalAttempt(c, attempt); err != nil { + return err + } + if err := s.flush(c); err != nil { + return fmt.Errorf("authoritative report remains queued: %w", err) + } + return nil +} + +func createFailureAllowsOfflineFallback(err error) bool { + var apiErr *apiError + if !errors.As(err, &apiErr) { + return true + } + return (apiErr.Code == "invalid_response" && + apiErr.Status >= http.StatusOK && + apiErr.Status < http.StatusMultipleChoices) || + apiErr.Status == http.StatusRequestTimeout || + apiErr.Status == http.StatusTooManyRequests || + apiErr.Status >= http.StatusInternalServerError +} + +func (s *gameOutcomeStore) completeColdFallback(attempt *proposalAttempt) error { + fallback := attempt.Fallback + planned := planGameAction(fallback.Action) + report := pendingReport{ + kind: "observe", + observe: protocol.ObserveRequest{ + ProtocolVersion: protocol.Version, + SessionID: attempt.Request.SessionID, + RequestID: "reconcile." + attempt.OperationID, + EventID: "fallback." + attempt.OperationID, + ObserverIDs: []string{attempt.Request.ActorID}, + Source: "basic-example", + Kind: "fallback_action", + Summary: "Local fallback " + fallback.Action.ID + ": " + planned.outcome, + Tags: []string{"fallback"}, + Importance: 3, + }, + } + _, err := s.applyAndEnqueueAttempt( + attempt.OperationID, + planned, + report, + attempt, + fallback.Tick, + func(tx *gameTransaction) error { + if planned.accepted { + s.applyEffect(tx, fallback.Action) + } + return nil + }, + ) + return err +} + +func (s *gameOutcomeStore) resolveProposalAttempt( + c *client, + attempt *proposalAttempt, +) error { + if err := s.markProposalSubmitted(attempt); err != nil { + return err + } var proposed protocol.ProposalResult - must(c.post("/v1/agent/propose", propose, &proposed)) - fmt.Printf("proposal: %s (%s)\n", proposed.Proposal.Action.Description, proposed.Proposal.Rationale) + if err := c.post("/v1/agent/propose", attempt.Request, &proposed); err != nil { + switch { + case isAmbiguousProposalError(err): + // The retained Attempt is intentionally not deleted. A restart must + // POST this exact request again; it must never choose a local fallback. + return fmt.Errorf( + "proposal_outcome_unknown: exact Proposal Attempt remains retained: %w", + err, + ) + case isStateChangedProposalError(err): + // state_changed proves that this request produced no Proposal, but + // the contract requires a new request_id. Retire this Attempt without + // applying an effect; the next invocation allocates a new sequence. + return s.retireProposalAttempt(attempt) + case isConfirmedNoProposalError(err): + // Validation and policy terminal errors prove that no Proposal was + // created, so the pre-persisted authored fallback is safe. + return s.completeColdFallback(attempt) + default: + // Identity/session/conflict errors are neither authority to execute + // fallback nor evidence that replaying elsewhere is safe. + return fmt.Errorf( + "proposal_failed_closed: exact Proposal Attempt remains retained: %w", + err, + ) + } + } + if err := validateProposalIdentity(attempt.Request, proposed.Proposal); err != nil { + return err + } + + planned := planGameAction(proposed.Proposal.Action) + var state protocol.SessionState + if err := c.post( + "/v1/session/get", + protocol.SessionRequest{ + ProtocolVersion: protocol.Version, + SessionID: attempt.Request.SessionID, + }, + &state, + ); err != nil { + // The exact Propose request remains durable. Replaying it is idempotent; + // consuming the Attempt here would instead turn an unverified response + // into game authority. + return fmt.Errorf( + "proposal_freshness_unknown: exact Proposal Attempt remains retained: %w", + err, + ) + } + if !proposalIsFresh(state, proposed.Proposal) { + planned = appliedOutcome{ + accepted: false, + outcome: "The game rejected a stale or inconsistent proposal before applying any effect.", + } + } commit := protocol.CommitRequest{ - ProtocolVersion: protocol.Version, SessionID: sessionID, RequestID: "commit." + suffix, - ProposalID: proposed.Proposal.ID, EventID: "event.mira-responded", Tick: 2, Accepted: true, - Outcome: "Mira asked what the player wanted remembered.", Tags: []string{"conversation"}, + ProtocolVersion: protocol.Version, + SessionID: attempt.Request.SessionID, + RequestID: "commit." + attempt.OperationID, + ProposalID: proposed.Proposal.ID, + EventID: "outcome." + attempt.OperationID, + Accepted: planned.accepted, + Outcome: planned.outcome, + Tags: []string{"conversation"}, } - must(c.post("/v1/action/commit", commit, &protocol.MutationResult{})) + _, err := s.applyAndEnqueueAttempt( + attempt.OperationID, + planned, + newCommitReport(attempt.OperationID, attempt.Request.ActorID, commit), + attempt, + proposed.Proposal.Tick, + func(tx *gameTransaction) error { + if planned.accepted { + s.applyEffect(tx, proposed.Proposal.Action) + } + return nil + }, + ) + return err +} - var state protocol.SessionState - must(c.post("/v1/session/get", protocol.SessionRequest{ProtocolVersion: protocol.Version, SessionID: sessionID}, &state)) - fmt.Printf("session %s revision=%d memories=%d next_think_tick=%d\n", state.SessionID, state.Revision, len(state.Actors["npc.mira"].Memories), state.Actors["npc.mira"].NextThinkTick) +func (s *gameOutcomeStore) retireProposalAttempt(attempt *proposalAttempt) error { + if err := s.checkDurability(); err != nil { + return err + } + if s.proposalAttempt != attempt { + return errors.New("Proposal Attempt changed before retirement") + } + return s.authoritativeTransaction(func(tx *gameTransaction) error { + tx.onRollback(func() { + s.proposalAttempt = attempt + }) + s.proposalAttempt = nil + return nil + }) +} + +func isAmbiguousProposalError(err error) bool { + var apiErr *apiError + if !errors.As(err, &apiErr) { + return true + } + return apiErr.Code == "proposal_outcome_unknown" || + (apiErr.Code == "invalid_response" && + apiErr.Status >= http.StatusOK && + apiErr.Status < http.StatusMultipleChoices) || + apiErr.Status == http.StatusRequestTimeout || + apiErr.Status >= http.StatusInternalServerError +} + +func isStateChangedProposalError(err error) bool { + var apiErr *apiError + return errors.As(err, &apiErr) && + apiErr.Status == http.StatusConflict && + apiErr.Code == "state_changed" +} + +func isConfirmedNoProposalError(err error) bool { + var apiErr *apiError + if !errors.As(err, &apiErr) { + return false + } + return (apiErr.Status == http.StatusBadRequest && + apiErr.Code == "invalid_request") || + (apiErr.Status == http.StatusUnprocessableEntity && + apiErr.Code == "no_safe_action") +} + +func validateProposalIdentity( + request protocol.ProposeRequest, + proposal protocol.ActionProposal, +) error { + if proposal.ID == "" || proposal.Action.ID == "" { + return errors.New("invalid_proposal_identity: proposal ID and action ID are required") + } + if proposal.SessionID != request.SessionID || + proposal.RequestID != request.RequestID || + proposal.ActorID != request.ActorID || + proposal.Tick != request.Tick { + return errors.New("invalid_proposal_identity: response does not match retained Proposal Attempt") + } + for _, candidate := range request.CandidateActions { + if reflect.DeepEqual(candidate, proposal.Action) { + return nil + } + } + return errors.New("invalid_proposal_identity: action is not an exact retained candidate") +} + +func proposalIsFresh(state protocol.SessionState, proposal protocol.ActionProposal) bool { + retained, ok := state.Proposals[proposal.ID] + if !ok || retained.Status != "pending" { + return false + } + if retained.SessionID != proposal.SessionID || + retained.RequestID != proposal.RequestID || + retained.ActorID != proposal.ActorID || + retained.Tick != proposal.Tick || + retained.BasedOnRevision != proposal.BasedOnRevision || + retained.BasedOnHeadHash != proposal.BasedOnHeadHash || + retained.BasedOnWorldRevision != proposal.BasedOnWorldRevision || + retained.CreatedRevision != proposal.CreatedRevision || + !reflect.DeepEqual(retained.Action, proposal.Action) { + return false + } + if retained.BasedOnWorldRevision > 0 { + return state.WorldRevision == retained.BasedOnWorldRevision + } + return state.Revision == retained.CreatedRevision +} + +func authoredFallback(request protocol.ProposeRequest, fallbackID string) (protocol.ActionProposal, error) { + for _, candidate := range request.CandidateActions { + if candidate.ID == fallbackID { + return protocol.ActionProposal{ + ID: "offline." + request.RequestID + "." + candidate.ID, + SessionID: request.SessionID, + RequestID: request.RequestID, + ActorID: request.ActorID, + Tick: request.Tick, + Action: candidate, + Stance: candidate.Kind, + Summary: "The game used its authored offline fallback.", + Rationale: "The Rin Sidecar was unavailable; world state remains game-owned.", + PolicySource: "adapter-offline", + Status: "offline", + }, nil + } + } + return protocol.ActionProposal{}, fmt.Errorf("invalid_fallback: %q is not a candidate action", fallbackID) +} + +func retrySameRequest(attempts int, request func() error) error { + var err error + for attempt := 0; attempt < attempts; attempt++ { + if err = request(); err == nil { + return nil + } + } + return err +} + +func isIrrecoverableCommitError(err error) bool { + var apiErr *apiError + if !errors.As(err, &apiErr) { + return false + } + switch apiErr.Code { + case "session_not_found", "unknown_proposal", "proposal_resolved", + "proposal_canceled", "proposal_stale": + return true + default: + return false + } +} + +type apiError struct { + Status int + Code string + Message string +} + +func (e *apiError) Error() string { + return e.Code + ": " + e.Message } func (c client) post(path string, input, output any) error { @@ -99,19 +1811,87 @@ func (c client) post(path string, input, output any) error { defer response.Body.Close() body, err := io.ReadAll(io.LimitReader(response.Body, 2<<20)) if err != nil { - return err + return &apiError{ + Status: response.StatusCode, + Code: "invalid_response", + Message: "could not read Rin response", + } } var result envelope if err := json.Unmarshal(body, &result); err != nil { - return fmt.Errorf("decode response: %w", err) + return &apiError{ + Status: response.StatusCode, + Code: "invalid_response", + Message: "could not decode Rin response", + } } if !result.OK { if result.Error == nil { - return errors.New("Rin returned an unspecified error") + return &apiError{ + Status: response.StatusCode, + Code: "invalid_response", + Message: "Rin returned an unspecified error", + } + } + return &apiError{ + Status: response.StatusCode, + Code: result.Error.Code, + Message: result.Error.Message, + } + } + if response.StatusCode < http.StatusOK || + response.StatusCode >= http.StatusMultipleChoices { + return &apiError{ + Status: response.StatusCode, + Code: "unexpected_http_status", + Message: "Rin returned success data with a non-success HTTP status", + } + } + if err := json.Unmarshal(result.Data, output); err != nil { + return &apiError{ + Status: response.StatusCode, + Code: "invalid_response", + Message: "could not decode Rin success data", + } + } + if err := validateSuccessResponseIdentity(input, output); err != nil { + return &apiError{ + Status: response.StatusCode, + Code: "invalid_response", + Message: err.Error(), + } + } + return nil +} + +func validateSuccessResponseIdentity(input, output any) error { + switch request := input.(type) { + case protocol.CommitRequest: + result, ok := output.(*protocol.MutationResult) + if !ok || result == nil || result.SessionID == "" { + return errors.New("Commit success data is missing a MutationResult") + } + if result.SessionID != request.SessionID { + return errors.New("Commit MutationResult session_id does not match the request") + } + case protocol.ObserveRequest: + result, ok := output.(*protocol.MutationResult) + if !ok || result == nil || result.SessionID == "" { + return errors.New("Observe success data is missing a MutationResult") + } + if result.SessionID != request.SessionID { + return errors.New("Observe MutationResult session_id does not match the request") + } + case protocol.SessionRequest: + state, ok := output.(*protocol.SessionState) + if !ok || state == nil || state.SessionID == "" { + return errors.New("Session GET success data is missing a SessionState") + } + if state.SessionID != request.SessionID { + return errors.New("SessionState session_id does not match the request") } - return fmt.Errorf("%s: %s", result.Error.Code, result.Error.Message) } - return json.Unmarshal(result.Data, output) + return nil } func must(err error) { diff --git a/examples/basic/main_test.go b/examples/basic/main_test.go new file mode 100644 index 0000000..b10151c --- /dev/null +++ b/examples/basic/main_test.go @@ -0,0 +1,2244 @@ +package main + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "syscall" + "testing" + "time" + + "github.com/sunrioa/rin/protocol" +) + +func TestOutcomeStoreRetainsExactRequestAndDoesNotReapply(t *testing.T) { + t.Parallel() + + var requestIDs []string + attempts := 0 + transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + var commit protocol.CommitRequest + if err := json.NewDecoder(request.Body).Decode(&commit); err != nil { + return nil, err + } + requestIDs = append(requestIDs, commit.RequestID) + attempts++ + body := `{"ok":true,"data":{"session_id":"session.example","revision":1,"duplicate":false}}` + if attempts == 1 { + body = `{"ok":false,"error":{"code":"temporary","message":"retry"}}` + } + return jsonResponse(body), nil + }) + + store := newGameOutcomeStore() + store.currentTick = func() int64 { return 17 } + operationID := "turn.example.1" + action := protocol.ActionSpec{ID: "talk"} + firstPlan := planGameAction(action) + commit := protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: "session.example", + RequestID: "commit." + operationID, + ProposalID: "proposal.example", + EventID: "outcome." + operationID, + Accepted: firstPlan.accepted, + Outcome: firstPlan.outcome, + } + report := newCommitReport(operationID, "npc.mira", commit) + transactionCalls := 0 + store.authoritativeTransaction = func(mutate func(*gameTransaction) error) error { + transactionCalls++ + err := runInMemoryGameTransaction(mutate) + if err == nil && (len(store.applied) != 1 || len(store.pending) != 1) { + t.Fatal("authoritative transaction did not publish marker and Outbox together") + } + return err + } + applyCalls := 0 + first, err := store.applyAndEnqueue( + operationID, + firstPlan, + report, + func(*gameTransaction) error { + applyCalls++ + return nil + }, + ) + if err != nil { + t.Fatalf("apply and enqueue: %v", err) + } + second, err := store.applyAndEnqueue( + operationID, + planGameAction(protocol.ActionSpec{ID: "unknown"}), + pendingReport{}, + func(*gameTransaction) error { + applyCalls++ + return nil + }, + ) + if err != nil { + t.Fatalf("re-enter apply and enqueue: %v", err) + } + if first != second { + t.Fatalf("re-entered operation was applied again: first=%+v second=%+v", first, second) + } + if transactionCalls != 1 || applyCalls != 1 { + t.Fatalf("transaction calls=%d apply calls=%d, want 1 each", transactionCalls, applyCalls) + } + pending := store.pending[operationID] + if pending.commit.Tick != 17 || pending.fallback.Tick != 17 { + t.Fatalf("occurrence tick commit=%d fallback=%d, want 17", pending.commit.Tick, pending.fallback.Tick) + } + c := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: transport}, + } + + if err := store.flush(&c); err == nil { + t.Fatal("first flush succeeded, want simulated report failure") + } + if store.pending[operationID].kind != "commit" { + t.Fatal("temporary failure converted Commit instead of retaining it") + } + if err := store.flush(&c); err != nil { + t.Fatalf("retry flush: %v", err) + } + if len(store.pending) != 0 { + t.Fatalf("pending count after acknowledgement = %d, want 0", len(store.pending)) + } + if len(requestIDs) != 2 || requestIDs[0] != commit.RequestID || requestIDs[1] != commit.RequestID { + t.Fatalf("retry request IDs = %v, want two copies of %q", requestIDs, commit.RequestID) + } +} + +func TestAuthoritativeTransactionFailureDoesNotApplyOrEnqueue(t *testing.T) { + t.Parallel() + + store := newGameOutcomeStore() + store.authoritativeTransaction = func(func(*gameTransaction) error) error { + return errors.New("transaction unavailable") + } + effect := false + _, err := store.applyAndEnqueue( + "turn.failed.1", + appliedOutcome{accepted: true, outcome: "planned"}, + newCommitReport( + "turn.failed.1", + "npc.mira", + protocol.CommitRequest{RequestID: "commit.turn.failed.1"}, + ), + func(*gameTransaction) error { + effect = true + return nil + }, + ) + if err == nil { + t.Fatal("applyAndEnqueue succeeded, want transaction error") + } + if effect || len(store.applied) != 0 || len(store.pending) != 0 { + t.Fatalf( + "failed transaction leaked state: effect=%t applied=%d pending=%d", + effect, + len(store.applied), + len(store.pending), + ) + } +} + +func TestEffectPanicRollsBackEffectMarkerAndOutbox(t *testing.T) { + t.Parallel() + + store := newGameOutcomeStore() + effect := false + _, err := store.applyAndEnqueue( + "turn.panic.1", + appliedOutcome{accepted: true, outcome: "planned"}, + newCommitReport( + "turn.panic.1", + "npc.mira", + protocol.CommitRequest{RequestID: "commit.turn.panic.1"}, + ), + func(tx *gameTransaction) error { + tx.onRollback(func() { effect = false }) + effect = true + panic("effect failed") + }, + ) + if err == nil || !strings.Contains(err.Error(), "panicked") { + t.Fatalf("applyAndEnqueue error = %v, want recovered effect panic", err) + } + if effect || len(store.applied) != 0 || len(store.pending) != 0 { + t.Fatalf( + "effect panic leaked state: effect=%t applied=%d pending=%d", + effect, + len(store.applied), + len(store.pending), + ) + } +} + +func TestIrrecoverableCommitAtomicallyConvertsToSafeObserve(t *testing.T) { + t.Parallel() + + var paths []string + transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + paths = append(paths, request.URL.Path) + switch request.URL.Path { + case "/v1/action/commit": + return jsonResponse( + `{"ok":false,"error":{"code":"unknown_proposal","message":"gone"}}`, + ), nil + case "/v1/session/observe": + var observe protocol.ObserveRequest + if err := json.NewDecoder(request.Body).Decode(&observe); err != nil { + return nil, err + } + if observe.EventID != "outcome.turn.1" || observe.Tick != 23 { + t.Errorf("Observe occurrence = %q@%d, want outcome.turn.1@23", observe.EventID, observe.Tick) + } + if len(observe.Facts) != 0 { + t.Errorf("degraded Observe contains unsafe facts: %+v", observe.Facts) + } + return jsonResponse(`{"ok":true,"data":{"session_id":"session.example","revision":4,"duplicate":false}}`), nil + default: + t.Fatalf("unexpected path %s", request.URL.Path) + return nil, nil + } + }) + store := newGameOutcomeStore() + store.currentTick = func() int64 { return 23 } + conversions := 0 + store.persistReportConversion = func(operationID string, replacement pendingReport) error { + conversions++ + if operationID != "turn.1" || replacement.kind != "observe" { + t.Fatalf("conversion = %q/%q", operationID, replacement.kind) + } + if store.pending[operationID].kind != "commit" { + t.Fatal("in-memory Commit changed before durable conversion succeeded") + } + return nil + } + _, err := store.applyAndEnqueue( + "turn.1", + appliedOutcome{accepted: true, outcome: "applied"}, + newCommitReport("turn.1", "npc.mira", protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: "session.example", + RequestID: "commit.turn.1", + ProposalID: "proposal.1", + EventID: "outcome.turn.1", + Accepted: true, + Outcome: "applied", + GoalUpdates: []protocol.GoalUpdate{{ + GoalID: "goal.connect", ProgressDelta: 1, + }}, + }), + func(*gameTransaction) error { return nil }, + ) + if err != nil { + t.Fatalf("apply and enqueue: %v", err) + } + c := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: transport}, + } + if err := store.flush(&c); err != nil { + t.Fatalf("flush converted report: %v", err) + } + if conversions != 1 || len(store.pending) != 0 { + t.Fatalf("conversions=%d pending=%d, want 1/0", conversions, len(store.pending)) + } + if strings.Join(paths, ",") != "/v1/action/commit,/v1/session/observe" { + t.Fatalf("report paths = %v", paths) + } +} + +func TestAcknowledgementMustPersistBeforeEviction(t *testing.T) { + t.Parallel() + + transport := roundTripFunc(func(*http.Request) (*http.Response, error) { + return jsonResponse(`{"ok":true,"data":{"session_id":"session.example","revision":1,"duplicate":false}}`), nil + }) + store := newGameOutcomeStore() + store.pending["turn.ack.1"] = pendingReport{ + kind: "observe", + observe: protocol.ObserveRequest{ + ProtocolVersion: protocol.Version, + SessionID: "session.example", + RequestID: "reconcile.turn.ack.1", + }, + } + store.persistReportAck = func(string) error { return errors.New("disk full") } + c := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: transport}, + } + if err := store.flush(&c); err == nil { + t.Fatal("flush succeeded despite durable acknowledgement failure") + } + if _, ok := store.pending["turn.ack.1"]; !ok { + t.Fatal("report evicted before durable acknowledgement") + } +} + +func TestMutationSuccessRequiresMatchingSessionAndRetainsOutbox(t *testing.T) { + t.Parallel() + + const sessionID = "session.expected" + for _, endpoint := range []struct { + name string + report pendingReport + }{ + { + name: "Commit", + report: pendingReport{ + kind: "commit", + commit: protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.turn.identity.1", + }, + }, + }, + { + name: "Observe", + report: pendingReport{ + kind: "observe", + observe: protocol.ObserveRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "observe.turn.identity.1", + }, + }, + }, + } { + endpoint := endpoint + for _, response := range []struct { + name string + body string + }{ + {name: "null data", body: `{"ok":true,"data":null}`}, + {name: "empty data", body: `{"ok":true,"data":{}}`}, + { + name: "wrong session", + body: `{"ok":true,"data":{"session_id":"session.other","revision":1}}`, + }, + } { + response := response + t.Run(endpoint.name+"/"+response.name, func(t *testing.T) { + t.Parallel() + const operationID = "turn.identity.1" + store := newGameOutcomeStore() + store.pending[operationID] = endpoint.report + acknowledgements := 0 + store.persistReportAck = func(string) error { + acknowledgements++ + return nil + } + c := client{ + baseURL: "http://rin.example", + http: &http.Client{ + Timeout: time.Second, + Transport: roundTripFunc( + func(*http.Request) (*http.Response, error) { + return jsonResponse(response.body), nil + }, + ), + }, + } + err := store.flush(&c) + var apiErr *apiError + if !errors.As(err, &apiErr) || + apiErr.Code != "invalid_response" || + apiErr.Status != http.StatusOK { + t.Fatalf("flush error = %v, want 2xx invalid_response", err) + } + if _, retained := store.pending[operationID]; !retained { + t.Fatal("invalid success response evicted the exact Outbox report") + } + if acknowledgements != 0 { + t.Fatalf( + "invalid success response persisted %d acknowledgements", + acknowledgements, + ) + } + }) + } + } +} + +func TestSessionGetSuccessRequiresMatchingSessionAndRetainsAttempt(t *testing.T) { + t.Parallel() + + for _, response := range []struct { + name string + body string + }{ + {name: "null data", body: `{"ok":true,"data":null}`}, + {name: "empty data", body: `{"ok":true,"data":{}}`}, + { + name: "wrong session", + body: `{"ok":true,"data":{"session_id":"session.other","revision":1}}`, + }, + } { + response := response + t.Run(response.name, func(t *testing.T) { + t.Parallel() + store := newGameOutcomeStore() + store.runID = time.Unix(0, 0).UTC().Format(exampleRunIDLayout) + store.create = exampleCreateRequest(store.runID) + attempt, err := store.retainProposalAttempt() + if err != nil { + t.Fatalf("retain Proposal Attempt: %v", err) + } + proposal := protocol.ActionProposal{ + ID: "proposal.identity.1", + SessionID: attempt.Request.SessionID, + RequestID: attempt.Request.RequestID, + ActorID: attempt.Request.ActorID, + Tick: attempt.Request.Tick, + CreatedRevision: 1, + Action: attempt.Request.CandidateActions[0], + Status: "pending", + } + applyCalls := 0 + store.applyEffect = func(*gameTransaction, protocol.ActionSpec) { + applyCalls++ + } + c := client{ + baseURL: "http://rin.example", + http: &http.Client{ + Timeout: time.Second, + Transport: roundTripFunc( + func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1/agent/propose": + return dataResponse(t, protocol.ProposalResult{ + Proposal: proposal, + }), nil + case "/v1/session/get": + return jsonResponse(response.body), nil + default: + t.Fatalf("unexpected request %s", request.URL.Path) + return nil, nil + } + }, + ), + }, + } + err = store.resolveProposalAttempt(&c, attempt) + var apiErr *apiError + if !errors.As(err, &apiErr) || + apiErr.Code != "invalid_response" || + apiErr.Status != http.StatusOK { + t.Fatalf("resolve error = %v, want 2xx invalid_response", err) + } + if store.proposalAttempt != attempt || !attempt.Submitted { + t.Fatal("invalid Session GET success abandoned the exact submitted Attempt") + } + if applyCalls != 0 || len(store.applied) != 0 || len(store.pending) != 0 { + t.Fatalf( + "invalid Session GET generated authority: calls=%d applied=%d pending=%d", + applyCalls, + len(store.applied), + len(store.pending), + ) + } + }) + } +} + +func TestPostRenameErrorsKeepMemoryAlignedWithReplacedState(t *testing.T) { + t.Parallel() + + t.Run("authoritative transaction does not roll back", func(t *testing.T) { + value := false + err := runPersistedGameTransaction( + func(tx *gameTransaction) error { + tx.onRollback(func() { value = false }) + value = true + return nil + }, + func() error { + return &stateFileReplacedError{err: errors.New("directory fsync")} + }, + ) + if err == nil || !stateFileWasReplaced(err) { + t.Fatalf("transaction error = %v, want post-Rename durability error", err) + } + if !value { + t.Fatal("post-Rename error rolled memory back behind replaced disk state") + } + }) + + t.Run("pre-Rename persistence error rolls back", func(t *testing.T) { + value := false + err := runPersistedGameTransaction( + func(tx *gameTransaction) error { + tx.onRollback(func() { value = false }) + value = true + return nil + }, + func() error { return errors.New("temporary file write") }, + ) + if err == nil { + t.Fatal("transaction succeeded despite pre-Rename persistence error") + } + if value { + t.Fatal("pre-Rename persistence error did not roll memory back") + } + }) + + t.Run("durable store blocks every later operation", func(t *testing.T) { + store := newGameOutcomeStore() + store.runID, store.create = newExampleRun(time.Now()) + store.authoritativeTransaction = func( + mutate func(*gameTransaction) error, + ) error { + return store.runPersistedMutation(mutate, func() error { + return &stateFileReplacedError{err: errors.New("directory fsync")} + }) + } + if _, err := store.retainProposalAttempt(); err == nil || + !stateFileWasReplaced(err) { + t.Fatalf("retain error = %v, want post-Rename durability error", err) + } + if store.proposalAttempt == nil || store.durabilityBlocked == nil { + t.Fatal("post-Rename mutation did not retain state and close durability gate") + } + networkCalls := 0 + c := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: roundTripFunc( + func(*http.Request) (*http.Response, error) { + networkCalls++ + return nil, errors.New("must not be reached") + }, + )}, + } + if err := store.runExampleInvocation(&c); err == nil || + !strings.Contains(err.Error(), "durability_unconfirmed") { + t.Fatalf("blocked invocation error = %v", err) + } + if _, err := store.retainProposalAttempt(); err == nil || + !strings.Contains(err.Error(), "durability_unconfirmed") { + t.Fatalf("blocked mutation error = %v", err) + } + if err := store.flush(&c); err == nil || + !strings.Contains(err.Error(), "durability_unconfirmed") { + t.Fatalf("blocked flush error = %v", err) + } + if networkCalls != 0 { + t.Fatalf("durability-blocked instance made %d network calls", networkCalls) + } + }) + + t.Run("Outbox acknowledgement evicts aligned memory", func(t *testing.T) { + store := newGameOutcomeStore() + store.pending["turn.ack.1"] = pendingReport{ + kind: "observe", + observe: protocol.ObserveRequest{ + ProtocolVersion: protocol.Version, + SessionID: "session.example", + RequestID: "reconcile.turn.ack.1", + }, + } + store.persistReportAck = func(string) error { + return &stateFileReplacedError{err: errors.New("directory fsync")} + } + networkCalls := 0 + c := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: roundTripFunc( + func(*http.Request) (*http.Response, error) { + networkCalls++ + return jsonResponse(`{"ok":true,"data":{"session_id":"session.example","revision":1}}`), nil + }, + )}, + } + if err := store.flush(&c); err == nil || !stateFileWasReplaced(err) { + t.Fatalf("flush error = %v, want post-Rename durability error", err) + } + if len(store.pending) != 0 { + t.Fatal("post-Rename acknowledgement left memory behind replaced disk state") + } + if err := store.flush(&c); err == nil || + !strings.Contains(err.Error(), "durability_unconfirmed") { + t.Fatalf("second flush error = %v, want closed durability gate", err) + } + if networkCalls != 1 { + t.Fatalf("closed durability gate allowed %d network calls", networkCalls) + } + }) + + t.Run("Commit conversion aligns memory", func(t *testing.T) { + store := newGameOutcomeStore() + store.pending["turn.convert.1"] = pendingReport{ + kind: "commit", + commit: protocol.CommitRequest{ + RequestID: "commit.turn.convert.1", + }, + fallback: protocol.ObserveRequest{ + RequestID: "reconcile.turn.convert.1", + }, + } + store.persistReportConversion = func(string, pendingReport) error { + return &stateFileReplacedError{err: errors.New("directory fsync")} + } + c := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: roundTripFunc( + func(*http.Request) (*http.Response, error) { + return apiErrorResponse(http.StatusNotFound, "unknown_proposal"), nil + }, + )}, + } + if err := store.flush(&c); err == nil || !stateFileWasReplaced(err) { + t.Fatalf("flush error = %v, want post-Rename durability error", err) + } + if store.pending["turn.convert.1"].kind != "observe" { + t.Fatal("post-Rename conversion left memory behind replaced disk state") + } + if store.durabilityBlocked == nil { + t.Fatal("post-Rename conversion did not close durability gate") + } + }) +} + +func TestInjectedDirectorySyncErrorsReplaceStateAndBlockDurability(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + syncErr error + }{ + {name: "EINVAL", syncErr: syscall.EINVAL}, + {name: "ENOTSUP", syncErr: syscall.ENOTSUP}, + } { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + statePath := filepath.Join(t.TempDir(), "game-state.json") + store := newGameOutcomeStore() + store.runID = time.Unix(0, 0).UTC().Format(exampleRunIDLayout) + store.create = exampleCreateRequest(store.runID) + syncCalls := 0 + store.authoritativeTransaction = func( + mutate func(*gameTransaction) error, + ) error { + return store.runPersistedMutation(mutate, func() error { + return persistGameOutcomeStateWithDirectorySync( + statePath, + store.snapshot(), + func(path string) error { + syncCalls++ + if path != filepath.Dir(statePath) { + t.Fatalf("unexpected sync path %q", path) + } + return test.syncErr + }, + ) + }) + } + + _, err := store.retainProposalAttempt() + if err == nil || + !stateFileWasReplaced(err) || + !errors.Is(err, test.syncErr) { + t.Fatalf("retain error = %v, want post-Rename %v", err, test.syncErr) + } + if syncCalls != 1 { + t.Fatalf("directory sync calls = %d, want 1", syncCalls) + } + if store.proposalAttempt == nil || store.durabilityBlocked == nil { + t.Fatal("post-Rename sync error did not align memory and block durability") + } + if _, statErr := os.Stat(statePath); statErr != nil { + t.Fatalf("Rename did not replace state before sync error: %v", statErr) + } + if _, err := store.retainProposalAttempt(); err == nil || + !strings.Contains(err.Error(), "durability_unconfirmed") { + t.Fatalf("later mutation error = %v, want closed durability gate", err) + } + if syncCalls != 1 { + t.Fatalf("blocked mutation performed another %d sync calls", syncCalls) + } + }) + } +} + +func TestFirstStateDirectoryCreationSyncsEveryCreatedParent(t *testing.T) { + t.Parallel() + + t.Run("syncs parent chain before final Rename directory", func(t *testing.T) { + root := t.TempDir() + stateDirectory := filepath.Join(root, "new-parent", "new-leaf") + statePath := filepath.Join(stateDirectory, "game-state.json") + var synced []string + if err := persistGameOutcomeStateWithDirectorySync( + statePath, + persistedGameOutcomeState{Version: gameOutcomeStateVersion}, + func(path string) error { + synced = append(synced, path) + return nil + }, + ); err != nil { + t.Fatalf("persist first state: %v", err) + } + want := []string{ + root, + filepath.Join(root, "new-parent"), + root, + root, + stateDirectory, + } + if !reflect.DeepEqual(synced, want) { + t.Fatalf("directory sync order = %v, want %v", synced, want) + } + }) + + t.Run("failed cleanup leaves a durable journal for reconstructed retry", func(t *testing.T) { + root := t.TempDir() + stateParent := filepath.Join(root, "new-parent") + stateDirectory := filepath.Join(stateParent, "new-leaf") + statePath := filepath.Join(stateDirectory, "game-state.json") + blockerPath := filepath.Join(stateDirectory, "concurrent-entry") + var firstSynced []string + err := persistGameOutcomeStateWithDirectorySync( + statePath, + persistedGameOutcomeState{Version: gameOutcomeStateVersion}, + func(path string) error { + firstSynced = append(firstSynced, path) + if path == stateParent { + if writeErr := os.WriteFile( + blockerPath, + []byte("keep"), + 0o600, + ); writeErr != nil { + t.Fatalf("create cleanup blocker: %v", writeErr) + } + return syscall.ENOTSUP + } + return nil + }, + ) + if err == nil || + !errors.Is(err, syscall.ENOTSUP) || + !strings.Contains(err.Error(), "clean up newly created state directories") || + stateFileWasReplaced(err) { + t.Fatalf( + "parent sync/cleanup error = %v, want explicit pre-Rename ENOTSUP", + err, + ) + } + if !reflect.DeepEqual(firstSynced, []string{root, stateParent}) { + t.Fatalf( + "first syncs = %v, want journal confirmation then %s", + firstSynced, + stateParent, + ) + } + if _, statErr := os.Stat(statePath); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("state file exists after parent sync failure: %v", statErr) + } + if _, statErr := os.Stat(blockerPath); statErr != nil { + t.Fatalf("failed cleanup did not leave its non-empty directory: %v", statErr) + } + absoluteStatePath, err := filepath.Abs(statePath) + if err != nil { + t.Fatalf("absolute state path: %v", err) + } + journalPath := filepath.Join( + root, + stateDirectorySyncJournalName(absoluteStatePath), + ) + if _, statErr := os.Stat(journalPath); statErr != nil { + t.Fatalf("durable parent-sync journal was not retained: %v", statErr) + } + + // This is a fresh call with no in-memory plan. It must rediscover the + // journal from disk and replay the original chain before writing state. + var retrySynced []string + if err := persistGameOutcomeStateWithDirectorySync( + statePath, + persistedGameOutcomeState{Version: gameOutcomeStateVersion}, + func(path string) error { + if len(retrySynced) < 2 { + if _, statErr := os.Stat(statePath); !errors.Is( + statErr, + os.ErrNotExist, + ) { + t.Fatalf( + "state file appeared before pending parent syncs: %v", + statErr, + ) + } + } + retrySynced = append(retrySynced, path) + return nil + }, + ); err != nil { + t.Fatalf("same-path retry: %v", err) + } + wantRetry := []string{ + root, + stateParent, + root, + root, + stateDirectory, + } + if !reflect.DeepEqual(retrySynced, wantRetry) { + t.Fatalf( + "same-path retry syncs = %v, want original parent chain %v", + retrySynced, + wantRetry, + ) + } + if _, statErr := os.Stat(journalPath); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("completed parent-sync journal was not cleared: %v", statErr) + } + }) + + t.Run("malformed and bounded journals fail closed", func(t *testing.T) { + for _, test := range []struct { + name string + body func(string, string) []byte + }{ + { + name: "malformed", + body: func(string, string) []byte { + return []byte(`{"version":1,"state_path":`) + }, + }, + { + name: "oversized", + body: func(string, string) []byte { + return []byte(strings.Repeat( + "x", + maxStateDirectorySyncJournalBytes+1, + )) + }, + }, + { + name: "too many parents", + body: func(statePath, root string) []byte { + parents := make( + []string, + maxStateDirectorySyncJournalParents+1, + ) + for index := range parents { + parents[index] = root + } + payload, err := json.Marshal( + persistedStateDirectorySyncJournal{ + Version: stateDirectorySyncJournalVersion, + StatePath: statePath, + Parents: parents, + }, + ) + if err != nil { + t.Fatalf("encode oversized parent journal: %v", err) + } + return payload + }, + }, + } { + test := test + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + statePath := filepath.Join( + root, + "new-parent", + "new-leaf", + "game-state.json", + ) + absoluteStatePath, err := filepath.Abs(statePath) + if err != nil { + t.Fatalf("absolute state path: %v", err) + } + journalPath := filepath.Join( + root, + stateDirectorySyncJournalName(absoluteStatePath), + ) + if err := os.WriteFile( + journalPath, + test.body(absoluteStatePath, root), + 0o600, + ); err != nil { + t.Fatalf("write corrupt journal: %v", err) + } + syncCalls := 0 + err = persistGameOutcomeStateWithDirectorySync( + statePath, + persistedGameOutcomeState{ + Version: gameOutcomeStateVersion, + }, + func(string) error { + syncCalls++ + return nil + }, + ) + if err == nil { + t.Fatal("corrupt parent-sync journal did not fail closed") + } + if syncCalls != 0 { + t.Fatalf("corrupt journal performed %d directory syncs", syncCalls) + } + if _, statErr := os.Stat(statePath); !errors.Is( + statErr, + os.ErrNotExist, + ) { + t.Fatalf("corrupt journal allowed state write: %v", statErr) + } + }) + } + }) + + t.Run("unconfirmed journal never authorizes directory creation", func(t *testing.T) { + root := t.TempDir() + stateDirectory := filepath.Join(root, "new-parent", "new-leaf") + statePath := filepath.Join(stateDirectory, "game-state.json") + for attempt := 1; attempt <= 2; attempt++ { + err := persistGameOutcomeStateWithDirectorySync( + statePath, + persistedGameOutcomeState{Version: gameOutcomeStateVersion}, + func(string) error { + return syscall.EINVAL + }, + ) + if err == nil || + !errors.Is(err, syscall.EINVAL) || + !strings.Contains(err.Error(), "confirm") { + t.Fatalf("attempt %d journal confirmation error = %v", attempt, err) + } + if _, statErr := os.Stat(stateDirectory); !errors.Is( + statErr, + os.ErrNotExist, + ) { + t.Fatalf( + "attempt %d unconfirmed journal authorized MkdirAll: %v", + attempt, + statErr, + ) + } + } + }) +} + +func TestDurableOutcomeStoreRestoresAndDrainsExactOutbox(t *testing.T) { + t.Parallel() + + statePath := filepath.Join(t.TempDir(), "game-state.json") + store, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("create durable store: %v", err) + } + store.currentTick = func() int64 { return 37 } + attempt, err := store.retainProposalAttempt() + if err != nil { + t.Fatalf("retain Proposal Attempt: %v", err) + } + operationID := attempt.OperationID + commit := protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: store.create.SessionID, + RequestID: "commit." + operationID, + ProposalID: "proposal.restart.1", + EventID: "outcome." + operationID, + Accepted: true, + Outcome: "applied once", + Tags: []string{"conversation"}, + } + applyCalls := 0 + _, err = store.applyAndEnqueueAttempt( + operationID, + appliedOutcome{accepted: true, outcome: commit.Outcome}, + newCommitReport(operationID, "npc.mira", commit), + attempt, + attempt.Request.Tick, + func(*gameTransaction) error { + applyCalls++ + return nil + }, + ) + if err != nil { + t.Fatalf("apply and durably enqueue: %v", err) + } + + restored, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("restore durable store: %v", err) + } + pending, ok := restored.pending[operationID] + if !ok { + t.Fatal("restart lost the pending authoritative report") + } + if pending.kind != "commit" || + pending.commit.RequestID != commit.RequestID || + pending.commit.Tick != 37 || + pending.fallback.Tick != 37 { + t.Fatalf("restored report changed: %+v", pending) + } + _, err = restored.applyAndEnqueue( + operationID, + appliedOutcome{accepted: false, outcome: "must not replace"}, + pendingReport{}, + func(*gameTransaction) error { + applyCalls++ + return nil + }, + ) + if err != nil { + t.Fatalf("re-enter restored operation: %v", err) + } + if applyCalls != 1 { + t.Fatalf("restored operation reapplied %d times, want exactly once", applyCalls) + } + + requests := 0 + transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + requests++ + var got protocol.CommitRequest + if err := json.NewDecoder(request.Body).Decode(&got); err != nil { + return nil, err + } + if got.RequestID != commit.RequestID || got.Tick != 37 { + t.Fatalf("retried Commit changed: %+v", got) + } + if requests == 1 { + return nil, errors.New("response lost") + } + return dataResponse(t, protocol.MutationResult{ + SessionID: commit.SessionID, + Revision: 2, + Duplicate: true, + }), nil + }) + c := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: transport}, + } + if err := restored.flush(&c); err == nil { + t.Fatal("ambiguous first flush succeeded") + } + afterFailure, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("restore after failed flush: %v", err) + } + if afterFailure.pending[operationID].commit.RequestID != commit.RequestID { + t.Fatal("failed flush did not retain the exact Commit durably") + } + if err := afterFailure.flush(&c); err != nil { + t.Fatalf("drain restored Outbox: %v", err) + } + afterAck, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("restore after acknowledgement: %v", err) + } + if len(afterAck.pending) != 0 { + t.Fatalf("acknowledged Outbox restored %d reports, want 0", len(afterAck.pending)) + } + if _, ok := afterAck.applied[operationID]; !ok { + t.Fatal("acknowledgement removed the authoritative applied marker") + } +} + +func TestDurableOutcomeStoreFailsClosedOnCorruptState(t *testing.T) { + t.Parallel() + + statePath := filepath.Join(t.TempDir(), "game-state.json") + if err := os.WriteFile(statePath, []byte(`{"version":1,"applied":`), 0o600); err != nil { + t.Fatalf("write corrupt state: %v", err) + } + if _, err := newDurableGameOutcomeStore(statePath); err == nil { + t.Fatal("corrupt state was treated as a clean first run") + } +} + +func TestProposalFreshnessAndInvalidFallback(t *testing.T) { + t.Parallel() + + proposal := protocol.ActionProposal{ + ID: "proposal.1", + SessionID: "session.1", + RequestID: "propose.1", + ActorID: "npc.mira", + Tick: 4, + CreatedRevision: 7, + Action: protocol.ActionSpec{ID: "wait", Kind: "wait"}, + Status: "pending", + } + state := protocol.SessionState{ + Revision: 7, + Proposals: map[string]protocol.ActionProposal{proposal.ID: proposal}, + } + if !proposalIsFresh(state, proposal) { + t.Fatal("unchanged pending proposal reported stale") + } + state.Revision = 8 + if proposalIsFresh(state, proposal) { + t.Fatal("non-arbitrated proposal remained fresh after revision changed") + } + proposal.BasedOnWorldRevision = 3 + state.Proposals[proposal.ID] = proposal + state.WorldRevision = 3 + if !proposalIsFresh(state, proposal) { + t.Fatal("arbitrated proposal did not use matching world revision") + } + forgedBase := proposal + forgedBase.BasedOnWorldRevision = 4 + state.WorldRevision = 4 + if proposalIsFresh(state, forgedBase) { + t.Fatal("response revision base overrode the server-retained Proposal base") + } + state.WorldRevision = 3 + forgedAction := proposal + forgedAction.Action = protocol.ActionSpec{ID: "talk", Kind: "dialogue"} + if proposalIsFresh(state, forgedAction) { + t.Fatal("response action differed from the server-retained Proposal") + } + state.Proposals[proposal.ID] = protocol.ActionProposal{ID: proposal.ID, Status: "accepted"} + if proposalIsFresh(state, proposal) { + t.Fatal("resolved proposal reported fresh") + } + + request := protocol.ProposeRequest{ + RequestID: "propose.1", + CandidateActions: []protocol.ActionSpec{{ + ID: "wait", Kind: "wait", + }}, + } + if _, err := authoredFallback(request, "missing"); err == nil || + !strings.Contains(err.Error(), "invalid_fallback") { + t.Fatalf("invalid fallback error = %v", err) + } +} + +func TestRetrySameRequestKeepsCreatePayloadStable(t *testing.T) { + t.Parallel() + + var payloads []string + transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + body, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + payloads = append(payloads, string(body)) + if len(payloads) == 1 { + return nil, errors.New("response lost") + } + return jsonResponse(`{"ok":true,"data":{"revision":1,"duplicate":true}}`), nil + }) + c := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: transport}, + } + create := protocol.CreateSessionRequest{ + ProtocolVersion: protocol.Version, + RequestID: "create.run.1", + SessionID: "session.run.1", + Binding: protocol.Binding{ + GameID: "game", ContentID: "base", ContentVersion: "1", ContentHash: "hash", + }, + Seed: 42, + Features: []string{protocol.FeatureOutcomeReporting}, + Actors: []protocol.ActorSeed{{ + ID: "npc.mira", Kind: "npc", DisplayName: "Mira", Enabled: true, + }}, + } + err := retrySameRequest(2, func() error { + return c.post("/v1/session/create", create, &protocol.MutationResult{}) + }) + if err != nil { + t.Fatalf("retry create: %v", err) + } + if len(payloads) != 2 || payloads[0] != payloads[1] { + t.Fatalf("create retry payloads differ: %q != %q", payloads[0], payloads[1]) + } +} + +func TestColdFallbackRestartsWithExactCreateThenDrainsObserve(t *testing.T) { + t.Parallel() + + statePath := filepath.Join(t.TempDir(), "game-state.json") + first, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("create durable store: %v", err) + } + stableCreate := first.create + first.currentTick = func() int64 { return 19 } + firstClient := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: roundTripFunc( + func(*http.Request) (*http.Response, error) { + return nil, errors.New("create never reached Rin") + }, + )}, + } + if err := first.runExampleInvocation(&firstClient); err != nil { + t.Fatalf("first-ever cold fallback: %v", err) + } + if first.proposalAttempt != nil || + len(first.applied) != 1 || + len(first.pending) != 1 { + t.Fatalf( + "cold fallback state attempt=%+v applied=%d pending=%d", + first.proposalAttempt, + len(first.applied), + len(first.pending), + ) + } + var retainedObserve protocol.ObserveRequest + for _, report := range first.pending { + retainedObserve = report.observe + } + + restarted, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("restart durable store: %v", err) + } + var paths []string + recoveryClient := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: roundTripFunc( + func(request *http.Request) (*http.Response, error) { + paths = append(paths, request.URL.Path) + switch request.URL.Path { + case "/v1/session/create": + var got protocol.CreateSessionRequest + if err := json.NewDecoder(request.Body).Decode(&got); err != nil { + return nil, err + } + if !reflect.DeepEqual(got, stableCreate) { + t.Fatalf("restarted Create changed: got=%+v want=%+v", got, stableCreate) + } + return dataResponse(t, protocol.MutationResult{ + SessionID: stableCreate.SessionID, + Revision: 1, + }), nil + case "/v1/session/observe": + var got protocol.ObserveRequest + if err := json.NewDecoder(request.Body).Decode(&got); err != nil { + return nil, err + } + if !reflect.DeepEqual(got, retainedObserve) { + t.Fatalf("restarted Observe changed: got=%+v want=%+v", got, retainedObserve) + } + return dataResponse(t, protocol.MutationResult{ + SessionID: stableCreate.SessionID, + Revision: 2, + }), nil + default: + t.Fatalf("unexpected recovery request %s", request.URL.Path) + return nil, nil + } + }, + )}, + } + if err := restarted.runExampleInvocation(&recoveryClient); err != nil { + t.Fatalf("restart recovery: %v", err) + } + if strings.Join(paths, ",") != "/v1/session/create,/v1/session/observe" { + t.Fatalf("recovery order = %v, want Create then Observe", paths) + } + afterRecovery, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("restore after recovery: %v", err) + } + if afterRecovery.create.SessionID != stableCreate.SessionID || + len(afterRecovery.pending) != 0 { + t.Fatalf( + "recovery lost identity or retained Outbox: session=%q pending=%d", + afterRecovery.create.SessionID, + len(afterRecovery.pending), + ) + } +} + +func TestAmbiguousProposeRestartsExactAttemptAndAppliesOnce(t *testing.T) { + t.Parallel() + + statePath := filepath.Join(t.TempDir(), "game-state.json") + first, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("create durable store: %v", err) + } + var proposalPayloads [][]byte + firstClient := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: roundTripFunc( + func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1/session/create": + return dataResponse(t, protocol.MutationResult{ + SessionID: first.create.SessionID, + Revision: 1, + }), nil + case "/v1/agent/propose": + body, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + proposalPayloads = append(proposalPayloads, body) + return nil, errors.New("response lost after durable Proposal") + default: + t.Fatalf("unexpected first-run request %s", request.URL.Path) + return nil, nil + } + }, + )}, + } + if err := first.runExampleInvocation(&firstClient); err == nil || + !strings.Contains(err.Error(), "proposal_outcome_unknown") { + t.Fatalf("ambiguous Proposal error = %v", err) + } + if first.proposalAttempt == nil || !first.proposalAttempt.Submitted || + len(first.applied) != 0 || len(first.pending) != 0 { + t.Fatalf( + "ambiguous Proposal was abandoned: attempt=%+v applied=%d pending=%d", + first.proposalAttempt, + len(first.applied), + len(first.pending), + ) + } + + restarted, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("restart durable store: %v", err) + } + attempt := restarted.proposalAttempt + action := attempt.Request.CandidateActions[0] + proposal := protocol.ActionProposal{ + ID: "proposal.recovered", + SessionID: attempt.Request.SessionID, + RequestID: attempt.Request.RequestID, + ActorID: attempt.Request.ActorID, + Tick: attempt.Request.Tick, + CreatedRevision: 2, + Action: action, + Status: "pending", + } + state := protocol.SessionState{ + ProtocolVersion: protocol.Version, + SessionID: attempt.Request.SessionID, + Revision: proposal.CreatedRevision, + Proposals: map[string]protocol.ActionProposal{ + proposal.ID: proposal, + }, + } + applyCalls := 0 + restarted.applyEffect = func(*gameTransaction, protocol.ActionSpec) { + applyCalls++ + } + secondClient := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: roundTripFunc( + func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1/session/create": + return dataResponse(t, protocol.MutationResult{ + SessionID: attempt.Request.SessionID, + Revision: 1, + }), nil + case "/v1/agent/propose": + body, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + proposalPayloads = append(proposalPayloads, body) + return dataResponse(t, protocol.ProposalResult{Proposal: proposal}), nil + case "/v1/session/get": + return dataResponse(t, state), nil + case "/v1/action/commit": + return nil, errors.New("Commit response lost") + default: + t.Fatalf("unexpected second-run request %s", request.URL.Path) + return nil, nil + } + }, + )}, + } + if err := restarted.runExampleInvocation(&secondClient); err == nil || + !strings.Contains(err.Error(), "authoritative report remains queued") { + t.Fatalf("second-run Commit error = %v", err) + } + if len(proposalPayloads) != 2 || + !reflect.DeepEqual(proposalPayloads[0], proposalPayloads[1]) { + t.Fatalf("recovered Proposal payload changed: %q != %q", proposalPayloads[0], proposalPayloads[1]) + } + if applyCalls != 1 || restarted.proposalAttempt != nil || + len(restarted.applied) != 1 || len(restarted.pending) != 1 { + t.Fatalf( + "recovered application calls=%d attempt=%+v applied=%d pending=%d", + applyCalls, + restarted.proposalAttempt, + len(restarted.applied), + len(restarted.pending), + ) + } + + draining, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("restore applied outcome: %v", err) + } + draining.applyEffect = func(*gameTransaction, protocol.ActionSpec) { + applyCalls++ + } + drainClient := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: roundTripFunc( + func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1/session/create", "/v1/action/commit": + return dataResponse(t, protocol.MutationResult{ + SessionID: attempt.Request.SessionID, + Revision: 3, + }), nil + default: + t.Fatalf("unexpected drain request %s", request.URL.Path) + return nil, nil + } + }, + )}, + } + if err := draining.runExampleInvocation(&drainClient); err != nil { + t.Fatalf("drain restored Commit: %v", err) + } + if applyCalls != 1 || len(draining.pending) != 0 { + t.Fatalf("restart reapplied effect: calls=%d pending=%d", applyCalls, len(draining.pending)) + } +} + +func TestMismatchedProposalIdentityFailsClosed(t *testing.T) { + t.Parallel() + + request := protocol.ProposeRequest{ + SessionID: "session.stable", + RequestID: "propose.stable.1", + ActorID: "npc.mira", + Tick: 9, + } + valid := protocol.ActionProposal{ + ID: "proposal.1", + SessionID: request.SessionID, + RequestID: request.RequestID, + ActorID: request.ActorID, + Tick: request.Tick, + Action: protocol.ActionSpec{ID: "wait"}, + } + cases := map[string]protocol.ActionProposal{ + "session": func() protocol.ActionProposal { + value := valid + value.SessionID = "session.other" + return value + }(), + "request": func() protocol.ActionProposal { + value := valid + value.RequestID = "propose.other" + return value + }(), + "actor": func() protocol.ActionProposal { + value := valid + value.ActorID = "npc.other" + return value + }(), + "tick": func() protocol.ActionProposal { + value := valid + value.Tick++ + return value + }(), + } + for name, proposal := range cases { + proposal := proposal + t.Run(name, func(t *testing.T) { + t.Parallel() + if err := validateProposalIdentity(request, proposal); err == nil || + !strings.Contains(err.Error(), "invalid_proposal_identity") { + t.Fatalf("identity mismatch error = %v", err) + } + }) + } + + statePath := filepath.Join(t.TempDir(), "game-state.json") + store, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("create durable store: %v", err) + } + applyCalls := 0 + store.applyEffect = func(*gameTransaction, protocol.ActionSpec) { + applyCalls++ + } + integrationClient := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: roundTripFunc( + func(httpRequest *http.Request) (*http.Response, error) { + switch httpRequest.URL.Path { + case "/v1/session/create": + return dataResponse(t, protocol.MutationResult{ + SessionID: store.create.SessionID, + Revision: 1, + }), nil + case "/v1/agent/propose": + var retained protocol.ProposeRequest + if err := json.NewDecoder(httpRequest.Body).Decode(&retained); err != nil { + return nil, err + } + return dataResponse(t, protocol.ProposalResult{ + Proposal: protocol.ActionProposal{ + ID: "proposal.mismatch", + SessionID: retained.SessionID, + RequestID: "wrong-request", + ActorID: retained.ActorID, + Tick: retained.Tick, + Action: retained.CandidateActions[0], + }, + }), nil + default: + t.Fatalf("unexpected identity-test request %s", httpRequest.URL.Path) + return nil, nil + } + }, + )}, + } + if err := store.runExampleInvocation(&integrationClient); err == nil || + !strings.Contains(err.Error(), "invalid_proposal_identity") { + t.Fatalf("mismatched Proposal result = %v", err) + } + if applyCalls != 0 || store.proposalAttempt == nil || + !store.proposalAttempt.Submitted || + len(store.applied) != 0 || + len(store.pending) != 0 { + t.Fatalf( + "identity mismatch did not fail closed: calls=%d attempt=%+v applied=%d pending=%d", + applyCalls, + store.proposalAttempt, + len(store.applied), + len(store.pending), + ) + } + restored, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("restore mismatched Proposal Attempt: %v", err) + } + if restored.proposalAttempt == nil || + restored.proposalAttempt.Request.RequestID != store.proposalAttempt.Request.RequestID { + t.Fatal("identity mismatch abandoned the exact durable Proposal Attempt") + } +} + +func TestAuthoritativeTickHighWaterSurvivesCleanRestart(t *testing.T) { + t.Parallel() + + statePath := filepath.Join(t.TempDir(), "game-state.json") + store, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("create durable store: %v", err) + } + store.currentTick = func() int64 { return 500 } + firstAttempt, err := store.retainProposalAttempt() + if err != nil { + t.Fatalf("retain first attempt: %v", err) + } + if firstAttempt.Request.Tick != 500 { + t.Fatalf("first attempt tick = %d, want 500", firstAttempt.Request.Tick) + } + if err := store.completeColdFallback(firstAttempt); err != nil { + t.Fatalf("complete first operation: %v", err) + } + observeClient := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: roundTripFunc( + func(*http.Request) (*http.Response, error) { + return dataResponse(t, protocol.MutationResult{ + SessionID: store.create.SessionID, + Revision: 1, + }), nil + }, + )}, + } + if err := store.flush(&observeClient); err != nil { + t.Fatalf("drain first operation: %v", err) + } + + restarted, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("clean restart: %v", err) + } + restarted.currentTick = func() int64 { return 0 } + nextAttempt, err := restarted.retainProposalAttempt() + if err != nil { + t.Fatalf("retain next attempt: %v", err) + } + if nextAttempt.Request.Tick != 501 { + t.Fatalf( + "clean-restart Proposal tick = %d, want persisted high-water + 1", + nextAttempt.Request.Tick, + ) + } +} + +func TestProposalTerminalClassification(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status int + code string + wantError string + wantAttempt bool + wantApplication bool + }{ + { + name: "invalid request is confirmed no Proposal", + status: http.StatusBadRequest, code: "invalid_request", + wantApplication: true, + }, + { + name: "state change retires old request ID", + status: http.StatusConflict, code: "state_changed", + }, + { + name: "no safe action is confirmed no Proposal", + status: http.StatusUnprocessableEntity, code: "no_safe_action", + wantApplication: true, + }, + { + name: "server failure is ambiguous", + status: http.StatusInternalServerError, code: "internal_error", + wantError: "proposal_outcome_unknown", wantAttempt: true, + }, + { + name: "request timeout is ambiguous", + status: http.StatusRequestTimeout, code: "request_timeout", + wantError: "proposal_outcome_unknown", wantAttempt: true, + }, + { + name: "explicit unknown is ambiguous", + status: http.StatusConflict, code: "proposal_outcome_unknown", + wantError: "proposal_outcome_unknown", wantAttempt: true, + }, + { + name: "missing session fails closed", + status: http.StatusNotFound, code: "session_not_found", + wantError: "proposal_failed_closed", wantAttempt: true, + }, + { + name: "request conflict fails closed", + status: http.StatusConflict, code: "request_id_conflict", + wantError: "proposal_failed_closed", wantAttempt: true, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + statePath := filepath.Join(t.TempDir(), "game-state.json") + store, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("create durable store: %v", err) + } + applyCalls := 0 + store.applyEffect = func(*gameTransaction, protocol.ActionSpec) { + applyCalls++ + } + requests := make(map[string]int) + c := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: roundTripFunc( + func(request *http.Request) (*http.Response, error) { + requests[request.URL.Path]++ + switch request.URL.Path { + case "/v1/session/create": + return dataResponse(t, protocol.MutationResult{ + SessionID: store.create.SessionID, + Revision: 1, + }), nil + case "/v1/agent/propose": + return apiErrorResponse(test.status, test.code), nil + case "/v1/session/observe": + return dataResponse(t, protocol.MutationResult{ + SessionID: store.create.SessionID, + Revision: 2, + }), nil + default: + t.Fatalf("unexpected request %s", request.URL.Path) + return nil, nil + } + }, + )}, + } + runErr := store.runExampleInvocation(&c) + if test.wantError == "" { + if runErr != nil { + t.Fatalf("run: %v", runErr) + } + } else if runErr == nil || + !strings.Contains(runErr.Error(), test.wantError) { + t.Fatalf("run error = %v, want %q", runErr, test.wantError) + } + if (store.proposalAttempt != nil) != test.wantAttempt { + t.Fatalf("retained Attempt = %+v, want retained=%t", store.proposalAttempt, test.wantAttempt) + } + if applyCalls != boolInt(test.wantApplication) { + t.Fatalf("application calls = %d, want %d", applyCalls, boolInt(test.wantApplication)) + } + if test.wantApplication && requests["/v1/session/observe"] != 1 { + t.Fatalf("safe terminal did not reconcile fallback: requests=%v", requests) + } + if !test.wantApplication && requests["/v1/session/observe"] != 0 { + t.Fatalf("failed-closed terminal emitted unreachable Observe: requests=%v", requests) + } + }) + } +} + +func TestCreateIdentityErrorsDoNotGenerateUnreachableFallback(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + status int + code string + }{ + {name: "invalid request", status: http.StatusBadRequest, code: "invalid_request"}, + {name: "session collision", status: http.StatusConflict, code: "session_exists"}, + {name: "unauthorized", status: http.StatusUnauthorized, code: "unauthorized"}, + } { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + statePath := filepath.Join(t.TempDir(), "game-state.json") + store, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("create durable store: %v", err) + } + c := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: roundTripFunc( + func(request *http.Request) (*http.Response, error) { + if request.URL.Path != "/v1/session/create" { + t.Fatalf("identity failure emitted %s", request.URL.Path) + } + return apiErrorResponse(test.status, test.code), nil + }, + )}, + } + if err := store.runExampleInvocation(&c); err == nil || + !strings.Contains(err.Error(), "failed closed") { + t.Fatalf("Create identity error = %v", err) + } + if store.proposalAttempt != nil || + len(store.applied) != 0 || + len(store.pending) != 0 { + t.Fatalf( + "Create identity error generated authority: attempt=%+v applied=%d pending=%d", + store.proposalAttempt, + len(store.applied), + len(store.pending), + ) + } + }) + } +} + +func TestStateChangedUsesNewRequestIDOnNextTurn(t *testing.T) { + t.Parallel() + + statePath := filepath.Join(t.TempDir(), "game-state.json") + store, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("create durable store: %v", err) + } + var requestIDs []string + stateChangedClient := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: roundTripFunc( + func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1/session/create": + return dataResponse(t, protocol.MutationResult{ + SessionID: store.create.SessionID, + Revision: 1, + }), nil + case "/v1/agent/propose": + var propose protocol.ProposeRequest + if err := json.NewDecoder(request.Body).Decode(&propose); err != nil { + return nil, err + } + requestIDs = append(requestIDs, propose.RequestID) + return apiErrorResponse(http.StatusConflict, "state_changed"), nil + default: + t.Fatalf("unexpected state-changed request %s", request.URL.Path) + return nil, nil + } + }, + )}, + } + if err := store.runExampleInvocation(&stateChangedClient); err != nil { + t.Fatalf("retire state-changed Attempt: %v", err) + } + if store.proposalAttempt != nil || len(store.applied) != 0 || len(store.pending) != 0 { + t.Fatalf("state_changed produced authority: attempt=%+v applied=%d pending=%d", store.proposalAttempt, len(store.applied), len(store.pending)) + } + + restarted, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("restart after state_changed: %v", err) + } + secondClient := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: roundTripFunc( + func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/v1/session/create": + return dataResponse(t, protocol.MutationResult{ + SessionID: restarted.create.SessionID, + Revision: 1, + }), nil + case "/v1/agent/propose": + var propose protocol.ProposeRequest + if err := json.NewDecoder(request.Body).Decode(&propose); err != nil { + return nil, err + } + requestIDs = append(requestIDs, propose.RequestID) + return nil, errors.New("ambiguous second Proposal") + default: + t.Fatalf("unexpected second-turn request %s", request.URL.Path) + return nil, nil + } + }, + )}, + } + if err := restarted.runExampleInvocation(&secondClient); err == nil || + !strings.Contains(err.Error(), "proposal_outcome_unknown") { + t.Fatalf("second turn error = %v", err) + } + if len(requestIDs) != 2 || requestIDs[0] == requestIDs[1] { + t.Fatalf("state_changed request IDs = %v, want a new ID", requestIDs) + } +} + +func TestRepeatedOfflineRoundsRecoverWithoutPermanentFallbackGate(t *testing.T) { + t.Parallel() + + statePath := filepath.Join(t.TempDir(), "game-state.json") + var stableCreate protocol.CreateSessionRequest + for round := uint64(1); round <= 2; round++ { + store, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("round %d load: %v", round, err) + } + if round == 1 { + stableCreate = store.create + } else if !reflect.DeepEqual(store.create, stableCreate) { + t.Fatalf("round %d changed stable Create", round) + } + offlineClient := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: roundTripFunc( + func(*http.Request) (*http.Response, error) { + return nil, errors.New("offline") + }, + )}, + } + if err := store.runExampleInvocation(&offlineClient); err != nil { + t.Fatalf("round %d offline fallback: %v", round, err) + } + if store.operationSequence != round || + store.proposalAttempt != nil || + len(store.pending) != 1 { + t.Fatalf( + "round %d fallback state sequence=%d attempt=%+v pending=%d", + round, + store.operationSequence, + store.proposalAttempt, + len(store.pending), + ) + } + + recovery, err := newDurableGameOutcomeStore(statePath) + if err != nil { + t.Fatalf("round %d recovery load: %v", round, err) + } + var paths []string + recoveryClient := client{ + baseURL: "http://rin.example", + http: &http.Client{Timeout: time.Second, Transport: roundTripFunc( + func(request *http.Request) (*http.Response, error) { + paths = append(paths, request.URL.Path) + switch request.URL.Path { + case "/v1/session/create", "/v1/session/observe": + return dataResponse(t, protocol.MutationResult{ + SessionID: stableCreate.SessionID, + Revision: round, + }), nil + default: + t.Fatalf("round %d unexpected recovery request %s", round, request.URL.Path) + return nil, nil + } + }, + )}, + } + if err := recovery.runExampleInvocation(&recoveryClient); err != nil { + t.Fatalf("round %d recover: %v", round, err) + } + if strings.Join(paths, ",") != "/v1/session/create,/v1/session/observe" { + t.Fatalf("round %d recovery order = %v", round, paths) + } + } +} + +func TestRestoreRejectsCrossInvariantCorruptionBeforeNetworking(t *testing.T) { + t.Parallel() + + sourcePath := filepath.Join(t.TempDir(), "source.json") + store, err := newDurableGameOutcomeStore(sourcePath) + if err != nil { + t.Fatalf("create source store: %v", err) + } + store.currentTick = func() int64 { return 12 } + attempt, err := store.retainProposalAttempt() + if err != nil { + t.Fatalf("retain source Attempt: %v", err) + } + attemptState := store.snapshot() + outcome := appliedOutcome{accepted: true, outcome: "applied once"} + commit := protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: store.create.SessionID, + RequestID: "commit." + attempt.OperationID, + ProposalID: "proposal.restore.1", + EventID: "outcome." + attempt.OperationID, + Accepted: outcome.accepted, + Outcome: outcome.outcome, + Tags: []string{"conversation"}, + } + if _, err := store.applyAndEnqueueAttempt( + attempt.OperationID, + outcome, + newCommitReport(attempt.OperationID, attempt.Request.ActorID, commit), + attempt, + attempt.Request.Tick, + func(*gameTransaction) error { return nil }, + ); err != nil { + t.Fatalf("create source Outbox: %v", err) + } + outboxState := store.snapshot() + operationID := attempt.OperationID + persistedMarker := outboxState.Applied[operationID] + if persistedMarker.ProposalID != commit.ProposalID || + persistedMarker.OccurrenceTick != + outboxState.Pending[operationID].Commit.Tick { + t.Fatalf( + "applied marker did not independently bind Proposal/tick: %+v", + persistedMarker, + ) + } + offlinePath := filepath.Join(t.TempDir(), "offline.json") + offlineStore, err := newDurableGameOutcomeStore(offlinePath) + if err != nil { + t.Fatalf("create offline source store: %v", err) + } + offlineStore.currentTick = func() int64 { return 15 } + offlineAttempt, err := offlineStore.retainProposalAttempt() + if err != nil { + t.Fatalf("retain offline source Attempt: %v", err) + } + if err := offlineStore.completeColdFallback(offlineAttempt); err != nil { + t.Fatalf("complete offline source fallback: %v", err) + } + offlineState := offlineStore.snapshot() + offlineOperationID := offlineAttempt.OperationID + + tests := []struct { + name string + source persistedGameOutcomeState + mutate func(*persistedGameOutcomeState) + }{ + { + name: "run and Create identity diverge", source: attemptState, + mutate: func(state *persistedGameOutcomeState) { state.RunID += ".other" }, + }, + { + name: "Create request fails protocol validation", source: attemptState, + mutate: func(state *persistedGameOutcomeState) { + state.Create.ProtocolVersion = "unsupported" + }, + }, + { + name: "Create lacks outcome reporting", source: attemptState, + mutate: func(state *persistedGameOutcomeState) { state.Create.Features = nil }, + }, + { + name: "Attempt operation is noncanonical", source: attemptState, + mutate: func(state *persistedGameOutcomeState) { + state.ProposalAttempt.OperationID = "turn.other.1" + }, + }, + { + name: "Attempt request fails protocol validation", source: attemptState, + mutate: func(state *persistedGameOutcomeState) { + state.ProposalAttempt.Request.CandidateActions[0].Kind = "" + }, + }, + { + name: "Attempt exceeds tick high-water", source: attemptState, + mutate: func(state *persistedGameOutcomeState) { + state.ProposalAttempt.Request.Tick = state.LastAuthoritativeTick + 1 + }, + }, + { + name: "Attempt fallback identity diverges", source: attemptState, + mutate: func(state *persistedGameOutcomeState) { + state.ProposalAttempt.Fallback.RequestID = "propose.wrong" + }, + }, + { + name: "pending request ID is not bound to key", source: outboxState, + mutate: func(state *persistedGameOutcomeState) { + report := state.Pending[operationID] + report.Commit.RequestID = "commit.turn.wrong.1" + state.Pending[operationID] = report + }, + }, + { + name: "pending event IDs diverge", source: outboxState, + mutate: func(state *persistedGameOutcomeState) { + report := state.Pending[operationID] + report.Fallback.EventID = "outcome.turn.wrong.1" + state.Pending[operationID] = report + }, + }, + { + name: "Commit replaces Proposal ID with another valid ID", source: outboxState, + mutate: func(state *persistedGameOutcomeState) { + report := state.Pending[operationID] + report.Commit.ProposalID = "proposal.valid-but-replaced" + state.Pending[operationID] = report + }, + }, + { + name: "marker replaces Proposal ID with another valid ID", source: outboxState, + mutate: func(state *persistedGameOutcomeState) { + marker := state.Applied[operationID] + marker.ProposalID = "proposal.valid-but-replaced" + state.Applied[operationID] = marker + }, + }, + { + name: "marker replaces occurrence tick with another valid tick", source: outboxState, + mutate: func(state *persistedGameOutcomeState) { + marker := state.Applied[operationID] + if marker.OccurrenceTick > 0 { + marker.OccurrenceTick-- + } else { + marker.OccurrenceTick++ + state.LastAuthoritativeTick++ + } + state.Applied[operationID] = marker + }, + }, + { + name: "Commit injects a valid Fact", source: outboxState, + mutate: func(state *persistedGameOutcomeState) { + report := state.Pending[operationID] + report.Commit.Facts = []protocol.Fact{{ + SubjectID: "npc.mira", + Predicate: "mood", + Object: "calm", + Confidence: 100, + }} + state.Pending[operationID] = report + }, + }, + { + name: "Commit injects a valid goal update", source: outboxState, + mutate: func(state *persistedGameOutcomeState) { + report := state.Pending[operationID] + report.Commit.GoalUpdates = []protocol.GoalUpdate{{ + GoalID: "goal.connect", ProgressDelta: 1, + }} + state.Pending[operationID] = report + }, + }, + { + name: "Commit injects a tag", source: outboxState, + mutate: func(state *persistedGameOutcomeState) { + report := state.Pending[operationID] + report.Commit.Tags = append(report.Commit.Tags, "injected") + state.Pending[operationID] = report + }, + }, + { + name: "Commit fallback injects a summary prefix", source: outboxState, + mutate: func(state *persistedGameOutcomeState) { + report := state.Pending[operationID] + report.Fallback.Summary = "Injected. " + report.Fallback.Summary + state.Pending[operationID] = report + }, + }, + { + name: "Commit fallback changes source", source: outboxState, + mutate: func(state *persistedGameOutcomeState) { + report := state.Pending[operationID] + report.Fallback.Source = "injected" + state.Pending[operationID] = report + }, + }, + { + name: "Commit fallback injects observer", source: outboxState, + mutate: func(state *persistedGameOutcomeState) { + report := state.Pending[operationID] + report.Fallback.ObserverIDs = append( + report.Fallback.ObserverIDs, + "npc.other", + ) + state.Pending[operationID] = report + }, + }, + { + name: "offline Observe injects a valid Fact", source: offlineState, + mutate: func(state *persistedGameOutcomeState) { + report := state.Pending[offlineOperationID] + report.Observe.Facts = []protocol.Fact{{ + SubjectID: "npc.mira", + Predicate: "mood", + Object: "calm", + Confidence: 100, + }} + state.Pending[offlineOperationID] = report + }, + }, + { + name: "offline Observe injects a tag", source: offlineState, + mutate: func(state *persistedGameOutcomeState) { + report := state.Pending[offlineOperationID] + report.Observe.Tags = append(report.Observe.Tags, "injected") + state.Pending[offlineOperationID] = report + }, + }, + { + name: "offline Observe injects a summary prefix", source: offlineState, + mutate: func(state *persistedGameOutcomeState) { + report := state.Pending[offlineOperationID] + report.Observe.Summary = "Injected. " + report.Observe.Summary + state.Pending[offlineOperationID] = report + }, + }, + { + name: "offline Observe changes source", source: offlineState, + mutate: func(state *persistedGameOutcomeState) { + report := state.Pending[offlineOperationID] + report.Observe.Source = "injected" + state.Pending[offlineOperationID] = report + }, + }, + { + name: "pending ticks diverge", source: outboxState, + mutate: func(state *persistedGameOutcomeState) { + report := state.Pending[operationID] + report.Fallback.Tick++ + state.Pending[operationID] = report + }, + }, + { + name: "Outbox exceeds tick high-water", source: outboxState, + mutate: func(state *persistedGameOutcomeState) { + state.LastAuthoritativeTick-- + }, + }, + { + name: "marker outcome differs from Commit", source: outboxState, + mutate: func(state *persistedGameOutcomeState) { + marker := state.Applied[operationID] + marker.Outcome = "different" + state.Applied[operationID] = marker + }, + }, + { + name: "Commit fails protocol validation", source: outboxState, + mutate: func(state *persistedGameOutcomeState) { + report := state.Pending[operationID] + report.Commit.ProposalID = "" + state.Pending[operationID] = report + }, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + state := clonePersistedState(t, test.source) + test.mutate(&state) + statePath := filepath.Join(t.TempDir(), "corrupt.json") + if err := persistGameOutcomeState(statePath, state); err != nil { + t.Fatalf("write corrupt state fixture: %v", err) + } + if _, err := newDurableGameOutcomeStore(statePath); err == nil { + t.Fatal("cross-invariant corruption restored as authoritative state") + } + }) + } +} + +func clonePersistedState( + t *testing.T, + state persistedGameOutcomeState, +) persistedGameOutcomeState { + t.Helper() + payload, err := json.Marshal(state) + if err != nil { + t.Fatalf("marshal state clone: %v", err) + } + var cloned persistedGameOutcomeState + if err := json.Unmarshal(payload, &cloned); err != nil { + t.Fatalf("unmarshal state clone: %v", err) + } + return cloned +} + +func boolInt(value bool) int { + if value { + return 1 + } + return 0 +} + +func dataResponse(t *testing.T, data any) *http.Response { + t.Helper() + payload, err := json.Marshal(struct { + OK bool `json:"ok"` + Data any `json:"data"` + }{OK: true, Data: data}) + if err != nil { + t.Fatalf("encode response: %v", err) + } + return jsonResponse(string(payload)) +} + +func jsonResponse(body string) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func apiErrorResponse(status int, code string) *http.Response { + response := jsonResponse( + `{"ok":false,"error":{"code":"` + code + `","message":"terminal"}}`, + ) + response.StatusCode = status + return response +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} diff --git a/examples/godot/example_npc.gd b/examples/godot/example_npc.gd index 29e32bf..0bd3927 100644 --- a/examples/godot/example_npc.gd +++ b/examples/godot/example_npc.gd @@ -1,13 +1,471 @@ extends Node +const MAX_PROTOCOL_INTEGER := 9223372036854775807 +const NPC_THINK_EVERY_TICKS := 5 + @onready var rin: RinClient = $RinClient +var _run_id := "" +var _operation_sequence := 0 +var _last_authoritative_tick := 0 +var _create_request: Dictionary = {} +var _applied_operations: Dictionary = {} +var _report_outbox: Dictionary = {} +var _proposal_attempts: Dictionary = {} +var _authoritative_state_ready := false +var _turn_running := false + + +func _ready() -> void: + # Recovery is a startup gate, not a best-effort background task. No new + # identity or game turn exists until storage has either restored a complete + # state or positively confirmed that this is a new run and saved its state. + _authoritative_state_ready = _restore_authoritative_state() + if not _authoritative_state_ready: + push_error("Authoritative Rin state could not be restored; NPC turns are disabled.") + func ask_npc_to_respond() -> void: - var created := await rin.create_session({ + if not _authoritative_state_ready: + push_error("Rin NPC turn refused until authoritative state recovery succeeds.") + return + if _turn_running: + push_warning("A Rin NPC turn is already running.") + return + _turn_running = true + await _run_npc_turn() + _turn_running = false + + +func _run_npc_turn() -> void: + if not _authoritative_state_ready: + return + var session_id := "playthrough." + _run_id + var resuming_attempt := _proposal_attempts.has(session_id) + # Keep this complete request stable. A lost response is retried on the next + # turn with the same request ID and byte-equivalent game-owned fields. + var created := await rin.create_session(_create_request.duplicate(true)) + if not created.get("ok", false): + if resuming_attempt: + push_warning("Rin create unavailable; the persisted Proposal attempt will fail closed.") + else: + # An empty Outbox and no prior Proposal attempt may proceed to an + # explicitly authored local fallback from cold start. + push_warning("Rin create unavailable; this turn may use the authored fallback.") + # Every authoritative entry retries pending Commit or fallback Observe + # reports before proposing or applying another action. + var pending_reported := await _flush_report_outbox() + if not pending_reported: + return + + var attempt: Dictionary + if resuming_attempt: + attempt = _proposal_attempts[session_id] + _operation_sequence = maxi(_operation_sequence, int(attempt["sequence"])) + else: + if _operation_sequence >= MAX_PROTOCOL_INTEGER: + push_error("Operation sequence exhausted; no new Proposal can be identified safely.") + return + var new_game_tick := _allocate_fresh_proposal_tick() + if new_game_tick < 0: + push_error("Authoritative tick exhausted; no new Proposal was submitted.") + return + var next_sequence := _operation_sequence + 1 + var new_operation_id := "%s.%d" % [_run_id, next_sequence] + var stable_request := _build_propose_request( + session_id, + new_operation_id, + new_game_tick, + ) + attempt = { + "operation_id": new_operation_id, + "sequence": next_sequence, + "request": stable_request.duplicate(true), + "fallback_action_id": "wait", + "job_id": "", + } + # Persist the entire stable request, operation ID, and consumed sequence + # before the first POST can create a durable Proposal Job. + if not _persist_new_proposal_attempt( + session_id, + attempt, + next_sequence, + new_game_tick, + ): + push_error("Could not durably save the Proposal attempt; nothing was submitted.") + return + _proposal_attempts[session_id] = attempt + _operation_sequence = next_sequence + _last_authoritative_tick = new_game_tick + + var operation_id := str(attempt["operation_id"]) + var request: Dictionary = attempt["request"] + var retained_job_id: String = attempt["job_id"] + var persist_job_id := func(job_id: String) -> bool: + return _record_proposal_job_id(session_id, operation_id, job_id) + var result := await rin.propose_with_fallback( + request, + str(attempt["fallback_action_id"]), + Callable(), + retained_job_id, + persist_job_id, + not resuming_attempt, + ) + var proposal = result.get("proposal") + if not proposal is Dictionary: + return + var proposal_tick := _read_nonnegative_protocol_tick(proposal.get("tick")) + if proposal_tick < 0: + push_error("Proposal tick is not an exact non-negative protocol integer.") + return + var planned := plan_action_in_game(proposal["action"]) + var report_entry: Dictionary + if result.get("committable", false): + var state_result := await rin.state({ + "protocol_version": RinClient.PROTOCOL_VERSION, + "session_id": session_id, + }) + if not state_result.get("ok", false): + # We already have an online proposal. Reject it authoritatively; + # never reinterpret a read failure as permission for a fallback. + planned = { + "action_id": str(proposal.get("action", {}).get("id", "")), + "accepted": false, + "outcome": "The game rejected the proposal because freshness could not be verified.", + } + else: + var state: Dictionary = state_result.get("data", {}) + if not _proposal_is_fresh(state, proposal, request): + planned = { + "action_id": str(proposal.get("action", {}).get("id", "")), + "accepted": false, + "outcome": "The game rejected a stale proposal before applying any effect.", + } + report_entry = _build_commit_report_entry( + str(request["session_id"]), + operation_id, + str(proposal["id"]), + 0, + planned, + ) + else: + # Authored local fallbacks have no Rin Proposal to Commit. Reconcile the + # game-owned effect as a stable Observe using these exact IDs and tick. + report_entry = { + "kind": "observe", + "request": _build_fallback_observe_request( + str(request["session_id"]), + operation_id, + 0, + planned, + ), + } + var applied := _apply_and_enqueue_authoritative_operation( + session_id, + operation_id, + planned, + report_entry, + proposal_tick, + ) + if applied.is_empty(): + return + await _flush_report_outbox() + + +func _restore_authoritative_state() -> bool: + var loaded = _load_authoritative_state() + if not loaded is Dictionary: + push_error("Authoritative state loader returned an invalid result.") + return false + var status := str(loaded.get("status", "error")) + if status == "loaded": + var state = loaded.get("state") + if not state is Dictionary or not _hydrate_authoritative_state(state): + push_error("Persisted authoritative state is missing, corrupt, or inconsistent.") + return false + return true + if status != "not_found": + push_error("Authoritative state load failed: " + str(loaded.get("error", "unknown"))) + return false + + # Only a positive not-found result may mint a new identity. Persist the + # complete initialized state before publishing it to the running scene. + var wall_clock := str(int(Time.get_unix_time_from_system() * 1000000.0)) + var new_run_id := wall_clock + "." + str(get_instance_id()) + var initialized_state := { + "schema_version": 2, + "run_id": new_run_id, + "operation_sequence": 0, + "last_authoritative_tick": 0, + "create_request": _build_create_request(new_run_id), + "proposal_attempts": {}, + "applied_operations": {}, + "report_outbox": {}, + } + if not _persist_authoritative_state_initialization(initialized_state): + push_error("Could not durably initialize authoritative state.") + return false + return _hydrate_authoritative_state(initialized_state) + + +func _load_authoritative_state() -> Dictionary: + # PRODUCTION RESTORE HOOK: synchronously read one serialized state object and + # return exactly one of: + # {"status": "loaded", "state": state} + # {"status": "not_found"} # storage positively confirmed no prior state + # {"status": "error", "error": "..."} + # Never translate an I/O/parse/version error into "not_found". This example + # intentionally stays disabled until the game wires its save provider. + return {"status": "error", "error": "restore hook not configured"} + + +func _persist_authoritative_state_initialization(_state: Dictionary) -> bool: + # PRODUCTION PERSISTENCE HOOK: atomically create-if-absent the entire state + # supplied here, including run ID, stable Create request, sequence, and + # high-water tick. A racing existing row or any storage uncertainty must + # return false and fail closed. + return true + + +func _hydrate_authoritative_state(state: Dictionary) -> bool: + var restored_run_id := str(state.get("run_id", "")) + var restored_sequence := _read_nonnegative_protocol_tick( + state.get("operation_sequence"), + ) + var restored_last_tick := _read_nonnegative_protocol_tick( + state.get("last_authoritative_tick"), + ) + var restored_create = state.get("create_request") + var restored_attempts = state.get("proposal_attempts") + var restored_applied = state.get("applied_operations") + var restored_outbox = state.get("report_outbox") + if ( + int(state.get("schema_version", 0)) != 2 + or restored_run_id == "" + or restored_sequence < 0 + or restored_last_tick < 0 + or not restored_create is Dictionary + or not restored_attempts is Dictionary + or not restored_applied is Dictionary + or not restored_outbox is Dictionary + ): + return false + var expected_session_id := "playthrough." + restored_run_id + var expected_create := _build_create_request(restored_run_id) + if ( + str(restored_create.get("session_id", "")) != expected_session_id + or str(restored_create.get("request_id", "")) != "create." + restored_run_id + or not _semantic_values_equal(restored_create, expected_create) + ): + return false + for session_key in restored_attempts: + var attempt = restored_attempts[session_key] + if ( + typeof(session_key) != TYPE_STRING + or not attempt is Dictionary + or str(session_key) != expected_session_id + ): + return false + var request = attempt.get("request") + var attempt_sequence := _read_nonnegative_protocol_tick(attempt.get("sequence")) + var attempt_operation_id := str(attempt.get("operation_id", "")) + var attempt_tick := ( + _read_nonnegative_protocol_tick(request.get("tick")) + if request is Dictionary + else -1 + ) + var canonical_sequence := _operation_sequence_from_id( + attempt_operation_id, + restored_run_id, + ) + var expected_request := ( + _build_propose_request( + expected_session_id, + attempt_operation_id, + attempt_tick, + ) + if attempt_tick >= 0 + else {} + ) + var attempt_job_id_value = attempt.get("job_id") + if typeof(attempt_job_id_value) != TYPE_STRING: + return false + var attempt_job_id: String = attempt_job_id_value + var expected_attempt := { + "operation_id": attempt_operation_id, + "sequence": attempt_sequence, + "request": expected_request, + "fallback_action_id": "wait", + "job_id": attempt_job_id, + } + if ( + not request is Dictionary + or attempt_operation_id == "" + or attempt_sequence <= 0 + or attempt_sequence != restored_sequence + or canonical_sequence != attempt_sequence + or str(request.get("session_id", "")) != expected_session_id + or str(request.get("request_id", "")) != "propose." + attempt_operation_id + or not _semantic_values_equal(request, expected_request) + or str(attempt.get("fallback_action_id", "")) != "wait" + or ( + not attempt_job_id.is_empty() + and not _is_valid_protocol_id(attempt_job_id) + ) + or not _semantic_values_equal(attempt, expected_attempt) + or attempt_tick < 0 + or attempt_tick > restored_last_tick + or restored_applied.has(attempt_operation_id) + or restored_outbox.has(attempt_operation_id) + ): + return false + for operation_id in restored_applied: + var applied_sequence := _operation_sequence_from_id(str(operation_id), restored_run_id) + var applied = restored_applied[operation_id] + if ( + typeof(operation_id) != TYPE_STRING + or applied_sequence <= 0 + or applied_sequence > restored_sequence + or not applied is Dictionary + or typeof(applied.get("action_id")) != TYPE_STRING + or typeof(applied.get("accepted")) != TYPE_BOOL + or typeof(applied.get("outcome")) != TYPE_STRING + or not _semantic_values_equal(applied, { + "action_id": applied.get("action_id"), + "accepted": applied.get("accepted"), + "outcome": applied.get("outcome"), + }) + ): + return false + for operation_id in restored_outbox: + var operation_key := str(operation_id) + var operation_sequence := _operation_sequence_from_id( + operation_key, + restored_run_id, + ) + var entry = restored_outbox[operation_id] + if ( + typeof(operation_id) != TYPE_STRING + or operation_sequence <= 0 + or operation_sequence > restored_sequence + or not entry is Dictionary + or not restored_applied.has(operation_key) + ): + return false + var kind := str(entry.get("kind", "")) + var request = entry.get("request") + var request_tick := ( + _read_nonnegative_protocol_tick(request.get("tick")) + if request is Dictionary + else -1 + ) + if ( + (kind != "commit" and kind != "observe") + or not request is Dictionary + or str(request.get("session_id", "")) != expected_session_id + or request_tick < 0 + or request_tick > restored_last_tick + ): + return false + var applied: Dictionary = restored_applied[operation_key] + var expected_entry: Dictionary + if kind == "commit": + var fallback = entry.get("fallback_request") + var proposal_id = request.get("proposal_id") if request is Dictionary else null + if ( + str(request.get("request_id", "")) != "commit." + operation_key + or str(request.get("event_id", "")) != "outcome." + operation_key + or not _is_valid_protocol_id(proposal_id) + or not fallback is Dictionary + or str(fallback.get("request_id", "")) != "reconcile." + operation_key + or str(fallback.get("session_id", "")) != str(request.get("session_id", "")) + or str(fallback.get("event_id", "")) != str(request.get("event_id", "")) + or _read_nonnegative_protocol_tick(fallback.get("tick")) != request_tick + or typeof(request.get("accepted")) != TYPE_BOOL + or request.get("accepted") != applied.get("accepted") + or typeof(request.get("outcome")) != TYPE_STRING + or request.get("outcome") != applied.get("outcome") + or str(fallback.get("source", "")) != "godot-example" + or str(fallback.get("kind", "")) != "action_outcome" + or str(fallback.get("summary", "")) + != "Authoritative outcome: " + str(applied.get("outcome")) + ): + return false + expected_entry = _build_commit_report_entry( + expected_session_id, + operation_key, + String(proposal_id), + request_tick, + applied, + ) + else: + var event_id := str(request.get("event_id", "")) + if ( + str(request.get("request_id", "")) != "reconcile." + operation_key + or event_id not in [ + "fallback." + operation_key, + "outcome." + operation_key, + ] + or str(request.get("source", "")) != "godot-example" + ): + return false + if event_id == "outcome." + operation_key: + if ( + str(request.get("kind", "")) != "action_outcome" + or str(request.get("summary", "")) + != "Authoritative outcome: " + str(applied.get("outcome")) + ): + return false + expected_entry = { + "kind": "observe", + "request": _build_outcome_observe_request( + expected_session_id, + operation_key, + request_tick, + applied, + ), + } + elif ( + str(request.get("kind", "")) != "fallback_action" + or str(request.get("summary", "")) + != "Local fallback %s: %s" % [ + str(applied.get("action_id")), + str(applied.get("outcome")), + ] + ): + return false + else: + expected_entry = { + "kind": "observe", + "request": _build_fallback_observe_request( + expected_session_id, + operation_key, + request_tick, + applied, + ), + } + # Rebuild the complete canonical DTO from durable operation identity, + # applied marker, Proposal identity, and occurrence tick. Dictionary size + # is part of semantic equality, so injected facts/goals/tags, alternate + # observers, or any non-canonical/default field fail restoration. + if not _semantic_values_equal(entry, expected_entry): + return false + + _run_id = restored_run_id + _operation_sequence = restored_sequence + _last_authoritative_tick = restored_last_tick + _create_request = restored_create.duplicate(true) + _proposal_attempts = restored_attempts.duplicate(true) + _applied_operations = restored_applied.duplicate(true) + _report_outbox = restored_outbox.duplicate(true) + return true + + +func _build_create_request(run_id: String) -> Dictionary: + return { "protocol_version": RinClient.PROTOCOL_VERSION, - "request_id": "create.playthrough-1", - "session_id": "playthrough-1", + "request_id": "create." + run_id, + "session_id": "playthrough." + run_id, "binding": { "game_id": "example-game", "content_id": "base", @@ -15,6 +473,7 @@ func ask_npc_to_respond() -> void: "content_hash": "example-content-hash", }, "seed": 42, + "features": ["outcome-reporting-v1"], "actors": [{ "id": "npc.mira", "kind": "npc", @@ -29,18 +488,23 @@ func ask_npc_to_respond() -> void: "target_progress": 3, "status": "active", }], - "think_every_ticks": 5, + "think_every_ticks": NPC_THINK_EVERY_TICKS, "enabled": true, }], - }) - if not created.get("ok", false): - return - var request := { + } + + +func _build_propose_request( + session_id: String, + operation_id: String, + tick: int, +) -> Dictionary: + return { "protocol_version": RinClient.PROTOCOL_VERSION, - "session_id": "playthrough-1", - "request_id": "propose.turn-19.mira", + "session_id": session_id, + "request_id": "propose." + operation_id, "actor_id": "npc.mira", - "tick": 19, + "tick": tick, "intent": "Choose how to respond to the player.", "tags": ["conversation", "trust"], "candidate_actions": [ @@ -48,24 +512,581 @@ func ask_npc_to_respond() -> void: {"id": "wait", "kind": "wait", "description": "Stay silent for now."}, ], } - var result := await rin.propose_with_fallback(request, "wait") - var proposal = result.get("proposal") - if not proposal is Dictionary: - return - apply_action_in_game(proposal["action"]) - if result.get("committable", false): - await rin.commit({ + + +func _build_commit_report_entry( + session_id: String, + operation_id: String, + proposal_id: String, + tick: int, + applied: Dictionary, +) -> Dictionary: + return { + "kind": "commit", + "request": { "protocol_version": RinClient.PROTOCOL_VERSION, - "session_id": request["session_id"], - "request_id": "commit.turn-19.mira", - "proposal_id": proposal["id"], - "event_id": "event.turn-19.mira", - "tick": request["tick"], - "accepted": true, - "outcome": "The game applied the advertised action.", - }) + "session_id": session_id, + "request_id": "commit." + operation_id, + "proposal_id": proposal_id, + "event_id": "outcome." + operation_id, + "tick": tick, + "accepted": applied["accepted"], + "outcome": applied["outcome"], + }, + # Persist this safe degradation payload in the same transaction as the + # Commit. It records only episodic memory: no goals, recent actions, + # scheduler changes, or relative facts are fabricated. + "fallback_request": _build_outcome_observe_request( + session_id, + operation_id, + tick, + applied, + ), + } -func apply_action_in_game(action: Dictionary) -> void: - # Replace with animation, navigation, dialogue, or combat commands owned by Godot. - print("Apply game-owned action: ", action.get("id", "")) +func _build_outcome_observe_request( + session_id: String, + operation_id: String, + tick: int, + applied: Dictionary, +) -> Dictionary: + return { + "protocol_version": RinClient.PROTOCOL_VERSION, + "session_id": session_id, + "request_id": "reconcile." + operation_id, + "event_id": "outcome." + operation_id, + "tick": tick, + # This adapter owns exactly one actor; never trust a persisted or remote + # observer identity when reconstructing an authoritative report. + "observer_ids": ["npc.mira"], + "source": "godot-example", + "kind": "action_outcome", + "summary": "Authoritative outcome: " + str(applied["outcome"]), + "tags": ["outcome-report"], + "importance": 3, + } + + +func _build_fallback_observe_request( + session_id: String, + operation_id: String, + tick: int, + applied: Dictionary, +) -> Dictionary: + return { + "protocol_version": RinClient.PROTOCOL_VERSION, + "session_id": session_id, + "request_id": "reconcile." + operation_id, + "event_id": "fallback." + operation_id, + "tick": tick, + "observer_ids": ["npc.mira"], + "source": "godot-example", + "kind": "fallback_action", + "summary": "Local fallback %s: %s" % [ + str(applied["action_id"]), + str(applied["outcome"]), + ], + "tags": ["fallback"], + "importance": 3, + } + + +func _is_valid_protocol_id(value: Variant) -> bool: + if typeof(value) != TYPE_STRING: + return false + var text: String = value + if text.is_empty() or text.length() > 96: + return false + for index in range(text.length()): + var code := text.unicode_at(index) + var is_letter := (code >= 65 and code <= 90) or (code >= 97 and code <= 122) + var is_digit := code >= 48 and code <= 57 + if index == 0: + if not is_letter and not is_digit: + return false + elif not is_letter and not is_digit and code not in [46, 95, 45]: + return false + return true + + +func _semantic_values_equal(left: Variant, right: Variant) -> bool: + var left_type := typeof(left) + var right_type := typeof(right) + if left_type == TYPE_INT and right_type == TYPE_INT: + return int(left) == int(right) + if left_type in [TYPE_INT, TYPE_FLOAT] and right_type in [TYPE_INT, TYPE_FLOAT]: + var left_number := float(left) + var right_number := float(right) + return ( + is_finite(left_number) + and is_finite(right_number) + and abs(left_number) <= 9007199254740991.0 + and abs(right_number) <= 9007199254740991.0 + and left_number == right_number + ) + if left_type != right_type: + return false + if left_type == TYPE_DICTIONARY: + if left.size() != right.size(): + return false + for key in left: + if not right.has(key) or not _semantic_values_equal(left[key], right[key]): + return false + return true + if left_type == TYPE_ARRAY: + if left.size() != right.size(): + return false + for index in range(left.size()): + if not _semantic_values_equal(left[index], right[index]): + return false + return true + return left == right + + +func _persist_new_proposal_attempt( + session_id: String, + attempt: Dictionary, + sequence: int, + authoritative_tick: int, +) -> bool: + if not _authoritative_state_ready: + return false + var request = attempt.get("request") + if ( + not request is Dictionary + or _operation_sequence >= MAX_PROTOCOL_INTEGER + or sequence != _operation_sequence + 1 + or authoritative_tick <= _last_authoritative_tick + or _operation_sequence_from_id(str(attempt.get("operation_id", "")), _run_id) + != sequence + or _read_nonnegative_protocol_tick(attempt.get("sequence")) != sequence + or str(request.get("session_id", "")) != session_id + or str(request.get("request_id", "")) + != "propose." + str(attempt.get("operation_id", "")) + or not _semantic_values_equal( + request, + _build_propose_request( + session_id, + str(attempt.get("operation_id", "")), + authoritative_tick, + ), + ) + or str(attempt.get("fallback_action_id", "")) != "wait" + or _read_nonnegative_protocol_tick(request.get("tick")) != authoritative_tick + ): + return false + # PRODUCTION PERSISTENCE HOOK: atomically save the complete attempt and the + # consumed game sequence and last_authoritative_tick before any online + # submission or local fallback. + return true + + +func _record_proposal_job_id( + session_id: String, + operation_id: String, + job_id: String, +) -> bool: + if not _is_valid_protocol_id(job_id): + return false + if not _proposal_attempts.has(session_id): + return false + var current: Dictionary = _proposal_attempts[session_id] + if str(current.get("operation_id", "")) != operation_id: + return false + var current_job_id = current.get("job_id") + if typeof(current_job_id) != TYPE_STRING: + return false + if current_job_id == job_id: + return true + var replacement := current.duplicate(true) + replacement["job_id"] = job_id + if not _persist_proposal_job_id(session_id, operation_id, job_id): + return false + _proposal_attempts[session_id] = replacement + return true + + +func _persist_proposal_job_id( + _session_id: String, + _operation_id: String, + _job_id: String, +) -> bool: + # PRODUCTION PERSISTENCE HOOK: durably attach the 202 Job ID to the matching + # stable attempt before the adapter starts polling it. + return true + + +func _apply_and_enqueue_authoritative_operation( + session_id: String, + operation_id: String, + planned: Dictionary, + report_entry: Dictionary, + proposal_tick: int, +) -> Dictionary: + if not _authoritative_state_ready: + return {} + if _applied_operations.has(operation_id): + # Atomic persistence guarantees the matching report entry also exists + # until acknowledgement; never execute the game effect again. + return _applied_operations[operation_id] + if not _persist_authoritative_transaction( + session_id, + operation_id, + planned, + report_entry, + proposal_tick, + ): + push_error("Authoritative game transaction rolled back; no report was queued.") + return {} + return _applied_operations.get(operation_id, {}) + + +func _persist_authoritative_transaction( + session_id: String, + operation_id: String, + planned: Dictionary, + report_entry: Dictionary, + proposal_tick: int, +) -> bool: + if not _authoritative_state_ready: + return false + # PRODUCTION PERSISTENCE HOOK: replace this whole body with one atomic game + # transaction. The actual game-state effect, applied marker, complete + # Commit/Observe entry (including its safe fallback), Proposal-attempt + # deletion, run ID, sequence, and last authoritative tick must commit or + # roll back together. + # Engine/native exceptions must abort that transaction; fallible game + # callbacks should return failure as below. + if not _proposal_attempts.has(session_id): + return false + var proposal_attempt: Dictionary = _proposal_attempts[session_id] + if str(proposal_attempt.get("operation_id", "")) != operation_id: + return false + var retained_request = proposal_attempt.get("request") + if not retained_request is Dictionary: + return false + var request_tick := _read_nonnegative_protocol_tick(retained_request.get("tick")) + if request_tick < 0 or proposal_tick < 0: + return false + # Engine frame counters commonly reset on process/scene restart. Never let + # that regress the outcome below either durable causal input. + var occurrence_tick := maxi( + maxi(_capture_authoritative_occurrence_tick(), _last_authoritative_tick), + maxi(request_tick, proposal_tick), + ) + var effective_planned := planned.duplicate(true) + if ( + effective_planned.get("accepted") == true + and occurrence_tick > MAX_PROTOCOL_INTEGER - NPC_THINK_EVERY_TICKS + ): + # An accepted Commit schedules npc.mira at tick + think_every_ticks. + # Convert to an authoritative rejection before any game effect when that + # addition would overflow int64; the resulting Commit remains valid. + effective_planned["accepted"] = false + effective_planned["outcome"] = ( + "The game rejected the action because the scheduler tick range is exhausted." + ) + var persisted_report: Dictionary + if report_entry.get("kind") == "commit": + var commit_request = report_entry.get("request") + if ( + not commit_request is Dictionary + or not _is_valid_protocol_id(commit_request.get("proposal_id")) + ): + return false + persisted_report = _build_commit_report_entry( + session_id, + operation_id, + String(commit_request["proposal_id"]), + occurrence_tick, + effective_planned, + ) + elif report_entry.get("kind") == "observe": + var observe_request = report_entry.get("request") + if ( + not observe_request is Dictionary + or observe_request.get("event_id") != "fallback." + operation_id + ): + return false + persisted_report = { + "kind": "observe", + "request": _build_fallback_observe_request( + session_id, + operation_id, + occurrence_tick, + effective_planned, + ), + } + else: + return false + var effect_result := _apply_planned_game_effect(effective_planned) + var rollback: Callable = effect_result.get("rollback", Callable()) + if not effect_result.get("ok", false): + # A fallible callback may have partially mutated game state before it + # reported failure. Run its registered inverse before aborting. + if rollback.is_valid(): + rollback.call() + return false + var previous_last_tick := _last_authoritative_tick + _last_authoritative_tick = occurrence_tick + _applied_operations[operation_id] = effective_planned + _report_outbox[operation_id] = persisted_report + # A succeeded online proposal (or confirmed-safe offline terminal) stops + # being resumable only inside this game-authoritative transaction. + _proposal_attempts.erase(session_id) + if not _commit_authoritative_game_transaction(operation_id, occurrence_tick): + _applied_operations.erase(operation_id) + _report_outbox.erase(operation_id) + _proposal_attempts[session_id] = proposal_attempt + _last_authoritative_tick = previous_last_tick + if rollback.is_valid(): + rollback.call() + return false + return true + + +func _flush_report_outbox() -> bool: + if not _authoritative_state_ready: + return false + var operation_ids := _report_outbox.keys() + operation_ids.sort() + for operation_id in operation_ids: + var entry: Dictionary = _report_outbox[operation_id] + var reported: Dictionary + if entry.get("kind") == "commit": + reported = await rin.commit(entry["request"]) + if not reported.get("ok", false): + var error_code := str(reported.get("error_code", "unknown")) + if not _is_irrecoverable_commit_error(error_code): + push_error("Commit temporarily failed; its exact request remains queued.") + return false + var replacement := entry.duplicate(true) + replacement["kind"] = "observe" + replacement["request"] = entry["fallback_request"].duplicate(true) + replacement.erase("fallback_request") + if not _persist_report_conversion(operation_id, replacement): + push_error("Could not durably convert Commit; original remains queued.") + return false + _report_outbox[operation_id] = replacement + entry = replacement + reported = await rin.observe(entry["request"]) + elif entry.get("kind") == "observe": + reported = await rin.observe(entry["request"]) + else: + push_error("Unknown authoritative report kind; entry remains queued.") + return false + if not reported.get("ok", false): + push_error("Game action already handled; the same report remains queued for retry.") + return false + if not _persist_report_acknowledgement(operation_id): + push_error("Report was acknowledged but durable Outbox deletion failed; retry is safe.") + return false + _report_outbox.erase(operation_id) + return true + + +func _persist_report_acknowledgement(_operation_id: String) -> bool: + # PRODUCTION PERSISTENCE HOOK: durably delete this Outbox row. The caller + # evicts its in-memory copy only after this returns true. + return true + + +func _persist_report_conversion( + _operation_id: String, + _replacement: Dictionary, +) -> bool: + # PRODUCTION PERSISTENCE HOOK: atomically replace the Commit row with the + # pre-persisted Observe fallback before the in-memory cache is changed. + return true + + +func _commit_authoritative_game_transaction( + _operation_id: String, + authoritative_tick: int, +) -> bool: + # PRODUCTION PERSISTENCE HOOK: return false (or abort the native transaction) + # if effect, applied marker, Outbox, run ID, sequence, and high-water tick + # cannot all commit. + return authoritative_tick == _last_authoritative_tick + + +func _capture_authoritative_occurrence_tick() -> int: + # Read the current game clock inside the transaction at actual apply/reject. + # Production games should inject their persisted simulation clock here. + return maxi(0, int(Engine.get_physics_frames())) + + +func _allocate_fresh_proposal_tick() -> int: + if _last_authoritative_tick >= MAX_PROTOCOL_INTEGER: + return -1 + # Preserve a larger live simulation clock, but advance the restored durable + # high-water by at least one when the engine clock reset or stood still. + return maxi( + _capture_authoritative_occurrence_tick(), + _last_authoritative_tick + 1, + ) + + +func _operation_sequence_from_id(operation_id: String, run_id: String) -> int: + var prefix := run_id + "." + if not operation_id.begins_with(prefix): + return -1 + var suffix := operation_id.substr(prefix.length()) + if suffix == "" or not suffix.is_valid_int(): + return -1 + var sequence := suffix.to_int() + # Reject signs and leading zeroes as non-canonical even if they parse. + if sequence <= 0 or str(sequence) != suffix: + return -1 + return sequence + + +func _read_nonnegative_protocol_tick(value: Variant) -> int: + if typeof(value) == TYPE_INT: + return int(value) if int(value) >= 0 else -1 + if typeof(value) == TYPE_FLOAT: + var number := float(value) + # JSON-decoded floats are accepted only while their integer identity is + # exact; larger values must be transported/decoded as native int64. + if ( + not is_finite(number) + or number < 0.0 + or number > 9007199254740991.0 + or floor(number) != number + ): + return -1 + return int(number) + return -1 + + +func _proposal_is_fresh( + state: Dictionary, + proposal: Dictionary, + stable_request: Dictionary, +) -> bool: + var proposals = state.get("proposals", {}) + var proposal_id := str(proposal.get("id", "")) + if proposal_id == "" or not proposals is Dictionary or not proposals.has(proposal_id): + return false + var retained = proposals[proposal_id] + if ( + not retained is Dictionary + or str(retained.get("id", "")) != proposal_id + or str(retained.get("status", "")) != "pending" + ): + return false + var retained_action = retained.get("action") + var response_action = proposal.get("action") + var retained_tick := _read_nonnegative_protocol_tick(retained.get("tick")) + var response_tick := _read_nonnegative_protocol_tick(proposal.get("tick")) + var retained_revision_base := _read_nonnegative_protocol_tick( + retained.get("based_on_revision"), + ) + var response_revision_base := _read_nonnegative_protocol_tick( + proposal.get("based_on_revision"), + ) + var retained_head_hash := str(retained.get("based_on_head_hash", "")) + var response_head_hash := str(proposal.get("based_on_head_hash", "")) + var retained_created := _read_nonnegative_protocol_tick( + retained.get("created_revision"), + ) + var retained_world_base := _read_nonnegative_protocol_tick( + retained.get("based_on_world_revision", 0), + ) + var response_created := _read_nonnegative_protocol_tick( + proposal.get("created_revision"), + ) + var response_world_base := _read_nonnegative_protocol_tick( + proposal.get("based_on_world_revision", 0), + ) + var response_action_id := ( + str(response_action.get("id", "")) + if response_action is Dictionary + else "" + ) + var stable_action: Dictionary = {} + var candidate_actions = stable_request.get("candidate_actions") + if candidate_actions is Array: + for candidate in candidate_actions: + if ( + candidate is Dictionary + and str(candidate.get("id", "")) == response_action_id + ): + stable_action = candidate + break + if ( + not retained_action is Dictionary + or not response_action is Dictionary + or str(retained.get("session_id", "")) == "" + or str(retained.get("session_id", "")) != str(proposal.get("session_id", "")) + or str(retained.get("request_id", "")) == "" + or str(retained.get("request_id", "")) != str(proposal.get("request_id", "")) + or str(retained.get("actor_id", "")) == "" + or str(retained.get("actor_id", "")) != str(proposal.get("actor_id", "")) + or retained_tick < 0 + or response_tick != retained_tick + or str(retained_action.get("id", "")) == "" + or str(retained_action.get("id", "")) != str(response_action.get("id", "")) + or str(retained_action.get("kind", "")) == "" + or str(retained_action.get("kind", "")) != str(response_action.get("kind", "")) + or not _semantic_values_equal(retained_action, response_action) + or stable_action.is_empty() + or not _semantic_values_equal(stable_action, response_action) + or retained_revision_base < 0 + or response_revision_base != retained_revision_base + or retained_head_hash != response_head_hash + or retained_created < 0 + or retained_world_base < 0 + or response_created != retained_created + or response_world_base != retained_world_base + ): + return false + if retained_world_base > 0: + return ( + _read_nonnegative_protocol_tick(state.get("world_revision")) + == retained_world_base + ) + return _read_nonnegative_protocol_tick(state.get("revision")) == retained_created + + +func _is_irrecoverable_commit_error(error_code: String) -> bool: + return error_code in [ + "session_not_found", + "unknown_proposal", + "proposal_resolved", + "proposal_canceled", + "proposal_stale", + ] + + +func plan_action_in_game(action: Dictionary) -> Dictionary: + var action_id := str(action.get("id", "")) + if action_id != "talk" and action_id != "wait": + return { + "action_id": action_id, + "accepted": false, + "outcome": "The game rejected an action outside its local allowlist.", + } + return { + "action_id": action_id, + "accepted": true, + "outcome": "The game applied the advertised action.", + } + + +func _apply_planned_game_effect(planned: Dictionary) -> Dictionary: + if not _authoritative_state_ready: + return {"ok": false, "rollback": Callable()} + # Replace with animation, navigation, dialogue, or combat commands owned by + # Godot. Register a rollback before mutating and return {"ok": false} on a + # fallible callback instead of publishing a marker or accepted Outbox. + if planned["accepted"]: + print("Apply game-owned action: ", planned["action_id"]) + var rollback := func() -> void: + if planned["accepted"]: + print("Roll back game-owned action: ", planned["action_id"]) + return { + "ok": true, + "rollback": rollback, + } diff --git a/examples/godot/rin_client.gd b/examples/godot/rin_client.gd index c4ce333..029f090 100644 --- a/examples/godot/rin_client.gd +++ b/examples/godot/rin_client.gd @@ -6,6 +6,14 @@ extends Node const PROTOCOL_VERSION := "rin.protocol/v1" const TERMINAL_STATES := ["succeeded", "failed", "stale", "canceled"] +const AMBIGUOUS_PROPOSAL_ERRORS := [ + "proposal_outcome_unknown", + "job_outcome_unknown", + "job_cancel_unconfirmed", + "job_timeout", + "job_id_persistence_failed", +] +const MAX_SAFE_JSON_INTEGER := 9007199254740991 @export var base_url := "http://127.0.0.1:7374" @export var token := "" @@ -30,6 +38,10 @@ func create_session(request: Dictionary) -> Dictionary: return await _json_request(HTTPClient.METHOD_POST, "/v1/session/create", request, [200]) +func state(request: Dictionary) -> Dictionary: + return await _json_request(HTTPClient.METHOD_POST, "/v1/session/get", request, [200]) + + func observe(request: Dictionary) -> Dictionary: return await _json_request(HTTPClient.METHOD_POST, "/v1/session/observe", request, [200]) @@ -75,43 +87,56 @@ func propose_with_fallback( request: Dictionary, fallback_action_id: String = "", cancel_check: Callable = Callable(), + known_job_id: String = "", + persist_job_id: Callable = Callable(), + allow_offline_before_submit: bool = true, ) -> Dictionary: + if not known_job_id.is_empty() and not _is_valid_protocol_id(known_job_id): + return _closed_result("invalid_job") var validation_error := _validate_endpoint() if not validation_error.is_empty(): - return _offline_result(request, fallback_action_id, "invalid_endpoint") - - var submission := await _json_request( - HTTPClient.METHOD_POST, - "/v1/jobs/propose", - request, - [202], - ) - if not submission.get("ok", false): - return _offline_result( - request, - fallback_action_id, - str(submission.get("error_code", "transport_failed")), - ) - var job_id := str(submission.get("data", {}).get("job_id", "")) + if allow_offline_before_submit and known_job_id.is_empty(): + return _offline_result(request, fallback_action_id, "invalid_endpoint") + if known_job_id.is_empty(): + return _closed_result("proposal_outcome_unknown") + return _closed_result("proposal_outcome_unknown", known_job_id) + + var job_id: String = known_job_id + var recovery_post_used := false if job_id.is_empty(): - return _offline_result(request, fallback_action_id, "invalid_submission") + var submission := await _submit_proposal(request, persist_job_id) + if not submission.get("ok", false): + var submission_error := str(submission.get("error_code", "transport_failed")) + if ( + allow_offline_before_submit + and submission_error == "transport_unavailable_before_send" + and not submission.has("status") + ): + # DNS/connect/TLS setup failed before an HTTP request could reach + # Rin and no Proposal Job was created. Resumed attempts disable + # this path even when the current transport is unavailable. + return _offline_result(request, fallback_action_id, submission_error) + # A timeout, connection reset, 5xx from a reverse proxy, or an + # oversized/malformed response may hide a durable job. Never execute + # a second, offline action after submission began. + return _closed_result( + "proposal_outcome_unknown", + _valid_submission_job_id_or(submission, ""), + ) + job_id = submission["job_id"] var deadline_msec := Time.get_ticks_msec() + int(job_deadline_seconds * 1000.0) while Time.get_ticks_msec() < deadline_msec: + if not _is_valid_protocol_id(job_id): + return _closed_result("invalid_job") if cancel_check.is_valid() and bool(cancel_check.call()): - await _json_request( - HTTPClient.METHOD_DELETE, - "/v1/jobs/" + job_id.uri_encode(), - {}, - [200], + return await _cancel_and_resolve( + request, + fallback_action_id, + job_id, + false, + "job_cancel_unconfirmed", ) - return { - "source": "canceled", - "committable": false, - "fallback_reason": "job_canceled", - "job_id": job_id, - "proposal": null, - } var response := await _json_request( HTTPClient.METHOD_GET, "/v1/jobs/" + job_id.uri_encode(), @@ -119,41 +144,354 @@ func propose_with_fallback( [200], ) if not response.get("ok", false): - return _offline_result( - request, - fallback_action_id, - str(response.get("error_code", "transport_failed")), - job_id, + if ( + str(response.get("error_code", "")) == "job_not_found" + and not recovery_post_used + ): + var recovered := await _submit_proposal(request, persist_job_id) + recovery_post_used = true + if not recovered.get("ok", false): + return _closed_result( + "proposal_outcome_unknown", + _valid_submission_job_id_or(recovered, job_id), + ) + job_id = recovered["job_id"] + continue + return _closed_result("job_outcome_unknown", job_id) + var job = response.get("data") + if not job is Dictionary: + return _closed_result("invalid_job", job_id) + if not _job_matches_request(job, job_id, request): + return _closed_result("invalid_job_identity", job_id) + var status_value = job.get("status") + if typeof(status_value) != TYPE_STRING: + return _closed_result("invalid_job", job_id) + var status: String = status_value + if not _job_shape_matches_status(job, status): + return _closed_result("invalid_job", job_id) + if status == "succeeded": + var proposal = job.get("proposal") + return ( + _sidecar_result(proposal, job_id) + if proposal is Dictionary and _proposal_matches_request(proposal, request) + else _closed_result( + "invalid_job_identity" if proposal is Dictionary else "invalid_job", + job_id, + ) ) - var job: Dictionary = response.get("data", {}) - var status := str(job.get("status", "")) - if status == "succeeded" and job.get("proposal") is Dictionary: - return { - "source": "sidecar", - "committable": true, - "fallback_reason": "", - "job_id": job_id, - "proposal": job["proposal"].duplicate(true), - } if status in TERMINAL_STATES: - var detail: Dictionary = job.get("error", {}) - return _offline_result( + var reason := _terminal_error_code(job) + if reason.is_empty(): + return _closed_result("job_outcome_unknown", job_id) + if reason == "proposal_outcome_unknown" and not recovery_post_used: + var recovered := await _submit_proposal(request, persist_job_id) + recovery_post_used = true + if not recovered.get("ok", false): + return _closed_result( + "proposal_outcome_unknown", + _valid_submission_job_id_or(recovered, job_id), + ) + job_id = recovered["job_id"] + continue + return _terminal_job_result( request, fallback_action_id, - str(detail.get("code", "job_" + status)), job_id, + job, + true, ) if status != "queued" and status != "running": - return _offline_result(request, fallback_action_id, "invalid_job", job_id) + return _closed_result("invalid_job", job_id) await get_tree().create_timer(poll_interval_seconds).timeout - await _json_request( + return await _cancel_and_resolve( + request, + fallback_action_id, + job_id, + true, + "job_outcome_unknown", + ) + + +func _submit_proposal( + request: Dictionary, + persist_job_id: Callable, +) -> Dictionary: + var submission := await _json_request( + HTTPClient.METHOD_POST, + "/v1/jobs/propose", + request, + [202], + ) + if not submission.get("ok", false): + return submission + var submission_data = submission.get("data") + if not submission_data is Dictionary: + return {"ok": false, "error_code": "invalid_job"} + var job_id_value = submission_data.get("job_id") + if not _is_valid_protocol_id(job_id_value): + return {"ok": false, "error_code": "invalid_job"} + var job_id: String = job_id_value + # The game persists the accepted Job ID before polling or returning control. + # If that durable callback fails, the stable request remains sufficient for + # a later idempotent POST, but this invocation must fail closed. + if persist_job_id.is_valid() and not bool(persist_job_id.call(job_id)): + return { + "ok": false, + "error_code": "job_id_persistence_failed", + "job_id": job_id, + } + return {"ok": true, "job_id": job_id} + + +func _cancel_and_resolve( + request: Dictionary, + fallback_action_id: String, + job_id: String, + allow_confirmed_terminal_fallback: bool, + unconfirmed_reason: String, +) -> Dictionary: + if not _is_valid_protocol_id(job_id): + return _closed_result("invalid_job") + var response := await _json_request( HTTPClient.METHOD_DELETE, "/v1/jobs/" + job_id.uri_encode(), {}, [200], ) - return _offline_result(request, fallback_action_id, "job_timeout", job_id) + if not response.get("ok", false): + return _closed_result(unconfirmed_reason, job_id) + var job = response.get("data") + if not job is Dictionary: + return _closed_result("invalid_job", job_id) + if not _job_matches_request(job, job_id, request): + return _closed_result("invalid_job_identity", job_id) + var status_value = job.get("status") + if typeof(status_value) != TYPE_STRING: + return _closed_result("invalid_job", job_id) + var status: String = status_value + if not _job_shape_matches_status(job, status): + return _closed_result("invalid_job", job_id) + if status == "succeeded": + var proposal = job.get("proposal") + return ( + _sidecar_result(proposal, job_id) + if proposal is Dictionary and _proposal_matches_request(proposal, request) + else _closed_result( + "invalid_job_identity" if proposal is Dictionary else "invalid_job", + job_id, + ) + ) + if status in TERMINAL_STATES: + return _terminal_job_result( + request, + fallback_action_id, + job_id, + job, + allow_confirmed_terminal_fallback, + ) + if status == "queued" or status == "running": + return _closed_result(unconfirmed_reason, job_id) + return _closed_result("invalid_job", job_id) + + +func _terminal_job_result( + request: Dictionary, + fallback_action_id: String, + job_id: String, + job: Dictionary, + allow_fallback: bool, +) -> Dictionary: + var reason := _terminal_error_code(job) + if reason.is_empty(): + return _closed_result("job_outcome_unknown", job_id) + if reason in AMBIGUOUS_PROPOSAL_ERRORS: + return _closed_result(reason, job_id) + if allow_fallback: + return _offline_result(request, fallback_action_id, reason, job_id) + return _closed_result(reason, job_id, "canceled") + + +func _sidecar_result(proposal: Dictionary, job_id: String) -> Dictionary: + return { + "source": "sidecar", + "committable": true, + "fallback_reason": "", + "job_id": job_id, + "proposal": proposal.duplicate(true), + } + + +func _job_matches_request( + job: Dictionary, + job_id: String, + request: Dictionary, +) -> bool: + return ( + _same_protocol_id(job.get("job_id"), job_id) + and _same_protocol_id(job.get("session_id"), request.get("session_id")) + and _same_protocol_id(job.get("request_id"), request.get("request_id")) + ) + + +func _proposal_matches_request( + proposal: Dictionary, + request: Dictionary, +) -> bool: + return ( + _is_valid_protocol_id(proposal.get("id")) + and _same_protocol_id(proposal.get("session_id"), request.get("session_id")) + and _same_protocol_id(proposal.get("request_id"), request.get("request_id")) + and _same_protocol_id(proposal.get("actor_id"), request.get("actor_id")) + and _same_json_integer(proposal.get("tick"), request.get("tick")) + and _proposal_action_matches_request(proposal.get("action"), request) + ) + + +func _terminal_error_code(job: Dictionary) -> String: + var detail = job.get("error") + if not detail is Dictionary: + return "" + var code = detail.get("code") + return String(code) if _is_valid_protocol_id(code) else "" + + +func _job_shape_matches_status(job: Dictionary, status: String) -> bool: + var has_proposal := job.has("proposal") + var has_error := job.has("error") + if status == "succeeded": + return has_proposal and job["proposal"] is Dictionary and not has_error + if status in ["failed", "stale", "canceled"]: + return not has_proposal and has_error and not _terminal_error_code(job).is_empty() + if status == "queued" or status == "running": + return not has_proposal and not has_error + return false + + +func _valid_submission_job_id_or(submission: Dictionary, fallback: String) -> String: + var candidate = submission.get("job_id") + if not _is_valid_protocol_id(candidate): + return fallback + var valid_job_id: String = candidate + return valid_job_id + + +func _proposal_action_matches_request(action: Variant, request: Dictionary) -> bool: + if not _is_valid_action_spec(action): + return false + var candidates = request.get("candidate_actions") + if not candidates is Array: + return false + for candidate in candidates: + if ( + _is_valid_action_spec(candidate) + and action == candidate + ): + return true + return false + + +func _is_valid_action_spec(value: Variant) -> bool: + if not value is Dictionary: + return false + if ( + not _is_valid_protocol_id(value.get("id")) + or not _is_valid_protocol_id(value.get("kind")) + or typeof(value.get("description")) != TYPE_STRING + ): + return false + var description: String = value["description"] + if description.strip_edges().is_empty() or description.length() > 300: + return false + var target_ids = value.get("target_ids", []) + if not target_ids is Array or target_ids.size() > 32: + return false + for target_id in target_ids: + if not _is_valid_protocol_id(target_id): + return false + var parameters = value.get("parameters", {}) + if not parameters is Dictionary or parameters.size() > 32: + return false + for key in parameters: + if ( + not _is_valid_protocol_id(key) + or typeof(parameters[key]) != TYPE_STRING + or String(parameters[key]).length() > 500 + ): + return false + return true + + +func _same_protocol_id(left: Variant, right: Variant) -> bool: + return ( + _is_valid_protocol_id(left) + and _is_valid_protocol_id(right) + and left == right + ) + + +func _is_valid_protocol_id(value: Variant) -> bool: + if typeof(value) != TYPE_STRING: + return false + var text: String = value + if text.is_empty() or text.length() > 96: + return false + for index in range(text.length()): + var code := text.unicode_at(index) + var is_letter := (code >= 65 and code <= 90) or (code >= 97 and code <= 122) + var is_digit := code >= 48 and code <= 57 + if index == 0: + if not is_letter and not is_digit: + return false + elif not is_letter and not is_digit and code not in [46, 95, 45]: + return false + return true + + +func _same_json_integer(left: Variant, right: Variant) -> bool: + var left_type := typeof(left) + var right_type := typeof(right) + if left_type not in [TYPE_INT, TYPE_FLOAT]: + return false + if right_type not in [TYPE_INT, TYPE_FLOAT]: + return false + if left_type == TYPE_INT and right_type == TYPE_INT: + return left >= 0 and right >= 0 and left == right + if ( + (left_type == TYPE_INT and (left < -MAX_SAFE_JSON_INTEGER or left > MAX_SAFE_JSON_INTEGER)) + or ( + right_type == TYPE_INT + and (right < -MAX_SAFE_JSON_INTEGER or right > MAX_SAFE_JSON_INTEGER) + ) + ): + return false + var left_number := float(left) + var right_number := float(right) + return ( + is_finite(left_number) + and is_finite(right_number) + and left_number >= 0.0 + and right_number >= 0.0 + and abs(left_number) <= float(MAX_SAFE_JSON_INTEGER) + and abs(right_number) <= float(MAX_SAFE_JSON_INTEGER) + and floor(left_number) == left_number + and floor(right_number) == right_number + and left_number == right_number + ) + + +func _closed_result( + reason: String, + job_id: String = "", + source: String = "error", +) -> Dictionary: + return { + "source": source, + "committable": false, + "fallback_reason": reason.left(96), + "job_id": job_id, + "proposal": null, + } func _json_request( @@ -181,7 +519,7 @@ func _json_request( var start_error := request.request(base_url + path, headers, method, body) if start_error != OK: request.queue_free() - return {"ok": false, "error_code": "transport_failed"} + return {"ok": false, "error_code": "transport_unavailable_before_send"} var completed: Array = await request.request_completed request.queue_free() var transport_result: int = completed[0] @@ -191,7 +529,15 @@ func _json_request( var error_code := ( "response_too_large" if transport_result == HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED - else "transport_failed" + else ( + "transport_unavailable_before_send" + if transport_result in [ + HTTPRequest.RESULT_CANT_CONNECT, + HTTPRequest.RESULT_CANT_RESOLVE, + HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR, + ] + else "transport_failed" + ) ) return {"ok": false, "error_code": error_code} if response_body.size() > max_response_bytes: @@ -226,12 +572,22 @@ func _offline_result( "job_id": job_id, "proposal": null, } - var selected: Dictionary = candidates[0] - if not fallback_action_id.is_empty(): + var selected: Dictionary + if fallback_action_id.is_empty(): + selected = candidates[0] + else: for candidate in candidates: if candidate is Dictionary and str(candidate.get("id", "")) == fallback_action_id: selected = candidate break + if selected.is_empty(): + return { + "source": "error", + "committable": false, + "fallback_reason": "invalid_fallback", + "job_id": job_id, + "proposal": null, + } var kind := str(selected.get("kind", "")) var stance := kind if kind in ["engage", "partial", "redirect", "refuse", "wait"] else "engage" var fingerprint := JSON.stringify({ diff --git a/examples/mods/bepinex-rin-npc/Plugin.cs b/examples/mods/bepinex-rin-npc/Plugin.cs index 53cf443..9370704 100644 --- a/examples/mods/bepinex-rin-npc/Plugin.cs +++ b/examples/mods/bepinex-rin-npc/Plugin.cs @@ -19,23 +19,41 @@ public sealed class Plugin : BaseUnityPlugin public const string PluginVersion = "0.1.0"; private const string ActorId = "npc.rin.companion"; + private const int MaxProposalPostsPerEntry = 2; private static readonly HashSet AllowedActions = new(StringComparer.Ordinal) { "talk", "wait", "refuse", }; + private static readonly HashSet TerminalCommitErrors = new(StringComparer.Ordinal) + { + "session_not_found", + "unknown_proposal", + "proposal_resolved", + }; + private static readonly HashSet AmbiguousProposalErrors = new(StringComparer.Ordinal) + { + "job_cancel_unconfirmed", + "job_outcome_unknown", + "job_timeout", + "proposal_outcome_unknown", + }; private readonly ConcurrentQueue mainThread = new(); + private readonly ConcurrentDictionary appliedOperations = new(); + private readonly ConcurrentDictionary outcomeOutbox = new(); private readonly SemaphoreSlim turnGate = new(1, 1); private readonly object sessionLock = new(); + private readonly object persistenceLock = new(); private RinClient? rin; private ConfigEntry? baseUrl; private ConfigEntry? demoHotkey; private Task? sessionTask; + private Dictionary? createSessionRequest; private string sessionId = string.Empty; - private string gameId = string.Empty; private long sequence; + private ProposalAttempt? proposalAttempt; public event Action? NpcActionReady; @@ -52,7 +70,14 @@ private void Awake() true, "Press F8 to request one example NPC turn."); sessionId = "bepinex." + Guid.NewGuid().ToString("N"); - gameId = Application.productName; + + // This complete Create request is retained for the lifetime of the + // session. Ambiguous retries reuse the same request_id, seed, binding, + // actors, and feature set. + createSessionRequest = CreateSessionPayload( + sessionId, + Application.productName, + DateTimeOffset.UtcNow.ToUnixTimeSeconds()); try { @@ -77,7 +102,9 @@ private void Update() } if (rin is not null && demoHotkey?.Value == true && Input.GetKeyDown(KeyCode.F8)) { - RequestNpcTurn("The player requested guidance from the companion.", Time.frameCount); + RequestNpcTurn( + "The player requested guidance from the companion.", + Time.frameCount); } } @@ -93,78 +120,414 @@ public void RequestNpcTurn(string observation, long gameTick) _ = RunNpcTurnAsync(observation, gameTick); } - private async Task RunNpcTurnAsync(string observation, long gameTick) + private async Task RunNpcTurnAsync(string observation, long observedGameTick) { if (rin is null) return; await turnGate.WaitAsync().ConfigureAwait(false); try { - await EnsureSessionAsync().ConfigureAwait(false); - var turn = Interlocked.Increment(ref sequence); - await rin.ObserveAsync(new Dictionary + var retainedAttempt = RetainedProposalAttempt(); + try + { + await EnsureSessionAsync().ConfigureAwait(false); + } + catch (Exception exception) + { + InvalidateSessionIfNotFound(exception); + // Offline fallback is authored game content, and is allowed + // only before Rin has supplied a proposal. If an earlier + // report or unresolved submission is pending, no new turn may + // start and the exact proposal identity remains retained. + if (retainedAttempt is not null || !outcomeOutbox.IsEmpty) + { + EnqueueIntegrationFailure(ExceptionCode(exception), actionHandled: true); + return; + } + var offlineTurn = Interlocked.Increment(ref sequence); + await ApplyOfflineFallbackOnMainThreadAsync( + sessionId + ".offline." + offlineTurn).ConfigureAwait(false); + EnqueueLog("Rin was unavailable; the authored offline fallback was applied and queued."); + return; + } + + // A new authoritative entry retries every retained report first. + // Temporary failure preserves it and prevents this turn. + await FlushOutcomeOutboxAsync(sessionId).ConfigureAwait(false); + var attempt = retainedAttempt + ?? RetainNewProposalAttempt(observation, observedGameTick); + + // Observe is itself idempotent. Replaying its exact retained + // payload closes an ambiguous Observe before resuming the same + // Propose request and never consumes a new sequence number. + await rin.ObserveAsync(attempt.ObserveRequest).ConfigureAwait(false); + var resolution = await ResolveProposalAttemptAsync(attempt).ConfigureAwait(false); + if (resolution.UseAuthoredFallback) { - ["protocol_version"] = RinClient.ProtocolVersion, - ["session_id"] = sessionId, - ["request_id"] = "observe." + turn, - ["event_id"] = "event." + turn, - ["tick"] = gameTick, - ["observer_ids"] = new[] { ActorId }, - ["source"] = "bepinex-example", - ["kind"] = "dialogue", - ["summary"] = observation, - ["tags"] = new[] { "conversation", "player-request" }, - ["importance"] = 3, - }).ConfigureAwait(false); - - var queued = await rin.SubmitProposalJobAsync(new Dictionary + if (string.Equals( + resolution.Reason, + "session_not_found", + StringComparison.Ordinal)) + { + lock (sessionLock) sessionTask = null; + } + await ApplyOfflineFallbackOnMainThreadAsync( + attempt.OperationId, + attempt).ConfigureAwait(false); + await ReportOutcomeAsync(attempt.OperationId).ConfigureAwait(false); + EnqueueLog( + "Rin confirmed a terminal proposal failure (" + resolution.Reason + + "); the authored fallback outcome was acknowledged."); + return; + } + var proposal = resolution.Proposal; + + // The game is the world authority. Re-read Rin immediately before + // apply. A temporary State failure fails closed and is never + // reinterpreted as permission to run the offline fallback. + FreshnessDecision freshness; + try { - ["protocol_version"] = RinClient.ProtocolVersion, - ["session_id"] = sessionId, - ["request_id"] = "propose." + turn, - ["actor_id"] = ActorId, - ["tick"] = gameTick + 1, - ["intent"] = "Choose one bounded response to the player.", - ["tags"] = new[] { "conversation" }, - ["candidate_actions"] = new object[] + var state = await rin.StateAsync(new Dictionary { - ActionSpec("talk", "dialogue", "offer one concrete hint"), - ActionSpec("wait", "wait", "ask the player to observe first"), - ActionSpec("refuse", "refuse", "decline an unsafe request"), + ["protocol_version"] = RinClient.ProtocolVersion, + ["session_id"] = sessionId, + }).ConfigureAwait(false); + freshness = ProposalFreshness(state, proposal); + } + catch (RinException exception) + { + InvalidateSessionIfNotFound(exception); + // No authored fallback is allowed after an online proposal. + // Record an authoritative rejection instead; if reporting is + // also unavailable its complete Commit remains in the Outbox. + freshness = FreshnessDecision.Unavailable; + } + await ApplyAndEnqueueOnMainThreadAsync( + attempt.OperationId, + sessionId, + proposal, + freshness, + attempt).ConfigureAwait(false); + + await ReportOutcomeAsync(attempt.OperationId).ConfigureAwait(false); + EnqueueLog("Rin outcome acknowledged."); + } + catch (Exception exception) + { + InvalidateSessionIfNotFound(exception); + EnqueueIntegrationFailure( + ExceptionCode(exception), + actionHandled: !outcomeOutbox.IsEmpty + || RetainedProposalAttempt() is not null); + } + finally + { + turnGate.Release(); + } + } + + private ProposalAttempt RetainNewProposalAttempt( + string observation, + long observedGameTick) + { + lock (persistenceLock) + { + if (proposalAttempt is not null) return proposalAttempt; + var turn = checked(sequence + 1); + var operationId = sessionId + "." + turn; + var requestId = "propose." + operationId; + var proposeTick = checked(observedGameTick + 1); + var retained = new ProposalAttempt( + sessionId, + operationId, + turn, + requestId, + proposeTick, + new Dictionary + { + ["protocol_version"] = RinClient.ProtocolVersion, + ["session_id"] = sessionId, + ["request_id"] = "observe." + operationId, + ["event_id"] = "event." + operationId, + ["tick"] = observedGameTick, + ["observer_ids"] = new[] { ActorId }, + ["source"] = "bepinex-example", + ["kind"] = "dialogue", + ["summary"] = observation, + ["tags"] = new[] { "conversation", "player-request" }, + ["importance"] = 3, }, - }).ConfigureAwait(false); - var jobId = RequiredString(queued, "job_id"); - var job = await rin.WaitForProposalAsync(jobId).ConfigureAwait(false); - var applied = await ApplyOnMainThreadAsync(job).ConfigureAwait(false); + new Dictionary + { + ["protocol_version"] = RinClient.ProtocolVersion, + ["session_id"] = sessionId, + ["request_id"] = requestId, + ["actor_id"] = ActorId, + ["tick"] = proposeTick, + ["intent"] = "Choose one bounded response to the player.", + ["tags"] = new[] { "conversation" }, + ["candidate_actions"] = new object[] + { + ActionSpec("talk", "dialogue", "offer one concrete hint"), + ActionSpec("wait", "wait", "ask the player to observe first"), + ActionSpec("refuse", "refuse", "decline an unsafe request"), + }, + }); + + // PRODUCTION PERSISTENCE HOOK: durably store the complete Observe + // and Propose requests, operation ID, sequence, and empty job ID + // before the first POST. All later entries restore this object. + sequence = turn; + proposalAttempt = retained; + return retained; + } + } + + private ProposalAttempt? RetainedProposalAttempt() + { + lock (persistenceLock) return proposalAttempt; + } - var proposal = RequiredObject(job, "proposal"); - await rin.CommitAsync(new Dictionary + private async Task ResolveProposalAttemptAsync( + ProposalAttempt attempt) + { + if (rin is null) throw new InvalidOperationException("Rin is not configured"); + var remainingPosts = MaxProposalPostsPerEntry; + while (true) + { + var jobId = attempt.JobId; + if (string.IsNullOrEmpty(jobId)) + { + if (remainingPosts <= 0) + throw UnknownProposalOutcome(); + var queued = await rin.SubmitProposalJobAsync( + attempt.ProposeRequest).ConfigureAwait(false); + jobId = RequiredString(queued, "job_id"); + PersistProposalJobId(attempt, jobId); + remainingPosts--; + } + + try { - ["protocol_version"] = RinClient.ProtocolVersion, - ["session_id"] = sessionId, - ["request_id"] = "commit." + turn, - ["proposal_id"] = RequiredString(proposal, "proposal_id"), - ["event_id"] = "outcome." + turn, - ["tick"] = gameTick + 2, - ["accepted"] = applied.Accepted, - ["outcome"] = applied.Outcome, - ["tags"] = new[] { "bepinex-example", "conversation" }, - }).ConfigureAwait(false); - EnqueueLog("Rin turn committed."); + // Validate the retained Job envelope before delegating polling + // to the SDK. Job identity is immutable for this handle, so a + // later terminal exception belongs to this exact attempt. + var currentJob = await rin.GetProposalJobAsync(jobId).ConfigureAwait(false); + ValidateJobIdentity(attempt, jobId, currentJob); + var currentStatus = RequiredString(currentJob, "status"); + if (string.Equals(currentStatus, "succeeded", StringComparison.Ordinal)) + return ProposalResolution.FromProposal( + ValidateProposalIdentity(attempt, jobId, currentJob)); + if (currentStatus is "failed" or "stale" or "canceled") + throw TerminalJobError(currentJob, currentStatus); + if (currentStatus is not ("queued" or "running")) + throw new RinProtocolException( + "invalid_job", + "Rin returned an unknown proposal Job status"); + + var job = await rin.WaitForProposalAsync(jobId).ConfigureAwait(false); + return ProposalResolution.FromProposal( + ValidateProposalIdentity(attempt, jobId, job)); + } + catch (RinApiException exception) + when (ShouldRepostProposal(exception.Code)) + { + // A missing Job or a terminal proposal_outcome_unknown is not + // permission to fall back. Forget only the lookup handle, then + // re-POST the byte-for-byte semantic request with the same + // request_id. The per-entry bound prevents a hot retry loop. + PersistProposalJobId(attempt, string.Empty); + if (remainingPosts <= 0) throw UnknownProposalOutcome(exception); + } + catch (RinApiException exception) + when (IsConfirmedSafeTerminal(exception)) + { + return ProposalResolution.AuthoredFallback(exception.Code); + } } - catch (RinException exception) + } + + private void PersistProposalJobId(ProposalAttempt attempt, string jobId) + { + lock (persistenceLock) { - EnqueueLog("Rin request failed: " + exception.Code, error: true); + if (!ReferenceEquals(proposalAttempt, attempt)) + throw new InvalidOperationException("Proposal attempt changed before its job ID was persisted"); + + // PRODUCTION PERSISTENCE HOOK: atomically update the optional job + // ID immediately after a 202 response and before the first GET. + attempt.JobId = jobId; } - catch (Exception) + } + + private static bool ShouldRepostProposal(string code) => + string.Equals(code, "job_not_found", StringComparison.Ordinal) + || string.Equals(code, "proposal_outcome_unknown", StringComparison.Ordinal); + + private static bool IsConfirmedSafeTerminal(RinApiException exception) => + exception.Status == 0 + && !AmbiguousProposalErrors.Contains(exception.Code); + + private static RinApiException UnknownProposalOutcome(Exception? inner = null) => + new( + "proposal_outcome_unknown", + inner is null + ? "Proposal outcome remains unknown after bounded same-request retries" + : "Proposal outcome remains unknown after bounded same-request retries: " + + inner.Message); + + private static JsonElement ValidateProposalIdentity( + ProposalAttempt attempt, + string expectedJobId, + JsonElement job) + { + ValidateJobIdentity(attempt, expectedJobId, job); + + var proposal = RequiredObject(job, "proposal"); + var proposalId = RequiredString(proposal, "id"); + if (string.IsNullOrWhiteSpace(proposalId) + || !string.Equals( + RequiredString(proposal, "session_id"), + attempt.SessionId, + StringComparison.Ordinal) + || !string.Equals( + RequiredString(proposal, "request_id"), + attempt.RequestId, + StringComparison.Ordinal) + || !string.Equals( + RequiredString(proposal, "actor_id"), + ActorId, + StringComparison.Ordinal) + || OptionalInt64(proposal, "tick", long.MinValue) + != attempt.ProposeTick) { - EnqueueLog("Rin integration failed before the proposal could be applied.", error: true); + throw new RinProtocolException( + "proposal_identity_mismatch", + "Rin returned a Proposal for a different retained proposal attempt"); } - finally + return proposal; + } + + private static void ValidateJobIdentity( + ProposalAttempt attempt, + string expectedJobId, + JsonElement job) + { + if (!string.Equals( + RequiredString(job, "job_id"), + expectedJobId, + StringComparison.Ordinal) + || !string.Equals( + RequiredString(job, "session_id"), + attempt.SessionId, + StringComparison.Ordinal) + || !string.Equals( + RequiredString(job, "request_id"), + attempt.RequestId, + StringComparison.Ordinal)) { - turnGate.Release(); + throw new RinProtocolException( + "proposal_identity_mismatch", + "Rin returned a Job for a different retained proposal attempt"); + } + } + + private static RinApiException TerminalJobError( + JsonElement job, + string status) + { + var code = "job_" + status; + var message = "Rin proposal Job ended as " + status; + if (job.TryGetProperty("error", out var error) + && error.ValueKind == JsonValueKind.Object) + { + if (error.TryGetProperty("code", out var errorCode) + && errorCode.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(errorCode.GetString())) + code = errorCode.GetString() ?? code; + if (error.TryGetProperty("message", out var errorMessage) + && errorMessage.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(errorMessage.GetString())) + message = errorMessage.GetString() ?? message; + } + return new RinApiException(code, message); + } + + private void InvalidateSessionIfNotFound(Exception exception) + { + for (Exception? current = exception; current is not null; current = current.InnerException) + { + if (current is RinException rinException + && string.Equals( + rinException.Code, + "session_not_found", + StringComparison.Ordinal)) + { + lock (sessionLock) sessionTask = null; + return; + } + } + } + + private async Task FlushOutcomeOutboxAsync(string currentSessionId) + { + foreach (var entry in outcomeOutbox) + { + if (entry.Value.SessionId == currentSessionId) + { + await ReportOutcomeAsync(entry.Key).ConfigureAwait(false); + } } } + private async Task ReportOutcomeAsync(string operationId) + { + if (rin is null) throw new InvalidOperationException("Rin is not configured"); + if (!outcomeOutbox.TryGetValue(operationId, out var pending)) return; + + if (pending.Kind == OutcomeKind.Observe) + { + await rin.ObserveAsync(pending.Request).ConfigureAwait(false); + AcknowledgeOutcome(operationId, pending); + return; + } + + try + { + await rin.CommitAsync(pending.Request).ConfigureAwait(false); + AcknowledgeOutcome(operationId, pending); + } + catch (RinException exception) when (TerminalCommitErrors.Contains(exception.Code)) + { + var converted = pending.AsDegradedObserve(); + if (!PersistOutboxConversion(operationId, pending, converted)) + throw new InvalidOperationException("Outbox conversion was not persisted"); + if (!outcomeOutbox.TryUpdate(operationId, converted, pending)) + throw new InvalidOperationException("Outbox changed during conversion"); + + if (string.Equals(exception.Code, "session_not_found", StringComparison.Ordinal)) + { + lock (sessionLock) sessionTask = null; + // The next entry recreates the exact session and flushes the + // converted Observe before beginning another turn. + throw; + } + + await rin.ObserveAsync(converted.Request).ConfigureAwait(false); + AcknowledgeOutcome(operationId, converted); + } + } + + private void AcknowledgeOutcome(string operationId, PendingOutcome pending) + { + // Durable ACK/delete succeeds before in-memory eviction. + if (!PersistOutboxAcknowledgement(operationId, pending)) + throw new InvalidOperationException("Outbox acknowledgement was not persisted"); + if (!outcomeOutbox.TryRemove(operationId, out var removed) + || !ReferenceEquals(removed, pending)) + throw new InvalidOperationException("Outbox changed during acknowledgement"); + } + private Task EnsureSessionAsync() { lock (sessionLock) @@ -175,100 +538,393 @@ private Task EnsureSessionAsync() private async Task CreateSessionAsync() { - if (rin is null) throw new InvalidOperationException("Rin is not configured"); + if (rin is null || createSessionRequest is null) + throw new InvalidOperationException("Rin is not configured"); try { - await rin.CreateSessionAsync(new Dictionary + // The retained object is deliberately reused on every retry. + await rin.CreateSessionAsync(createSessionRequest).ConfigureAwait(false); + } + catch + { + lock (sessionLock) sessionTask = null; + throw; + } + } + + private static Dictionary CreateSessionPayload( + string currentSessionId, + string currentGameId, + long seed) => new() + { + ["protocol_version"] = RinClient.ProtocolVersion, + ["request_id"] = "create." + currentSessionId, + ["session_id"] = currentSessionId, + ["binding"] = new Dictionary + { + ["game_id"] = currentGameId, + ["content_id"] = "rin-bepinex-example", + ["content_version"] = PluginVersion, + ["content_hash"] = "sha256:" + new string('0', 64), + }, + ["seed"] = seed, + ["features"] = new[] { "outcome-reporting-v1" }, + ["actors"] = new object[] + { + new Dictionary { - ["protocol_version"] = RinClient.ProtocolVersion, - ["request_id"] = "create." + sessionId, - ["session_id"] = sessionId, - ["binding"] = new Dictionary + ["id"] = ActorId, + ["kind"] = "npc", + ["display_name"] = "Rin Companion", + ["traits"] = new[] { "observant", "careful" }, + ["boundaries"] = new object[] { - ["game_id"] = gameId, - ["content_id"] = "rin-bepinex-example", - ["content_version"] = PluginVersion, - ["content_hash"] = "sha256:" + new string('0', 64), + new Dictionary + { + ["id"] = "boundary.no-cheats", + ["description"] = "Never suggest cheats or bypassing game rules.", + ["trigger_tags"] = new[] { "unsafe" }, + ["response"] = "refuse", + }, }, - ["seed"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - ["actors"] = new object[] + ["goals"] = new object[] { new Dictionary { - ["id"] = ActorId, - ["kind"] = "npc", - ["display_name"] = "Rin Companion", - ["traits"] = new[] { "observant", "careful" }, - ["boundaries"] = new object[] - { - new Dictionary - { - ["id"] = "boundary.no-cheats", - ["description"] = "Never suggest cheats or bypassing game rules.", - ["trigger_tags"] = new[] { "unsafe" }, - ["response"] = "refuse", - }, - }, - ["goals"] = new object[] - { - new Dictionary - { - ["id"] = "goal.help-player", - ["description"] = "Help the player make one informed choice.", - ["priority"] = 4, - ["preferred_actions"] = new[] { "talk" }, - ["progress"] = 0, - ["target_progress"] = 3, - ["status"] = "active", - }, - }, - ["think_every_ticks"] = 20, - ["enabled"] = true, + ["id"] = "goal.help-player", + ["description"] = "Help the player make one informed choice.", + ["priority"] = 4, + ["preferred_actions"] = new[] { "talk" }, + ["progress"] = 0, + ["target_progress"] = 3, + ["status"] = "active", }, }, - }).ConfigureAwait(false); - } - catch - { - lock (sessionLock) sessionTask = null; - throw; - } - } + ["think_every_ticks"] = 20, + ["enabled"] = true, + }, + }, + }; - private Task ApplyOnMainThreadAsync(JsonElement job) + private Task ApplyAndEnqueueOnMainThreadAsync( + string operationId, + string currentSessionId, + JsonElement proposal, + FreshnessDecision freshness, + ProposalAttempt completedAttempt) { - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - mainThread.Enqueue(() -> + if (appliedOperations.TryGetValue(operationId, out var stored)) + return Task.FromResult(stored); + + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + mainThread.Enqueue(() => { try { - var proposal = RequiredObject(job, "proposal"); + if (appliedOperations.TryGetValue(operationId, out var existing)) + { + completion.SetResult(existing); + return; + } var action = RequiredObject(proposal, "action"); var actionId = RequiredString(action, "id"); - if (!AllowedActions.Contains(actionId)) + AppliedAction planned; + Action applyGameState; + if (freshness == FreshnessDecision.Unavailable) { - completion.SetResult(new AppliedAction(false, "The game rejected an action outside its allowlist.")); - return; + planned = new AppliedAction( + false, + "The game rejected the proposal because freshness could not be verified."); + applyGameState = () => { }; + } + else if (freshness == FreshnessDecision.Stale) + { + planned = new AppliedAction( + false, + "The game rejected a stale proposal before applying it."); + applyGameState = () => { }; + } + else if (!AllowedActions.Contains(actionId)) + { + planned = new AppliedAction( + false, + "The game rejected an action outside its allowlist."); + applyGameState = () => { }; } - var line = actionId switch + else { - "talk" => "Companion: Check your resources before choosing the next route.", - "wait" => "Companion: Let us observe one more cycle before acting.", - "refuse" => "Companion: I cannot help with an action that breaks the game rules.", - _ => throw new InvalidOperationException("allowlist changed during apply"), + var line = actionId switch + { + "talk" => "Companion: Check your resources before choosing the next route.", + "wait" => "Companion: Let us observe one more cycle before acting.", + "refuse" => "Companion: I cannot help with an action that breaks the game rules.", + _ => throw new InvalidOperationException("allowlist changed during apply"), + }; + planned = new AppliedAction(true, line); + applyGameState = () => + { + // Subscriber exceptions are part of the fallible game + // transaction and must not leave an accepted marker. + NpcActionReady?.Invoke(actionId, line); + Logger.LogMessage(line); + }; + } + + // Outcome time cannot precede either the retained request or + // the returned Proposal, even when both happen in one frame. + var occurrenceTick = Math.Max( + (long)Time.frameCount, + Math.Max( + completedAttempt.ProposeTick, + OptionalInt64( + proposal, + "tick", + completedAttempt.ProposeTick))); + var pending = CommitPending( + currentSessionId, + operationId, + RequiredString(proposal, "id"), + occurrenceTick, + planned); + if (!PersistAuthoritativeTransaction( + operationId, + planned, + pending, + completedAttempt, + applyGameState)) + throw new InvalidOperationException("Authoritative game transaction was not persisted"); + completion.SetResult(planned); + } + catch (Exception exception) + { + completion.SetException(exception); + } + }); + return completion.Task; + } + + private Task ApplyOfflineFallbackOnMainThreadAsync( + string operationId, + ProposalAttempt? completedAttempt = null) + { + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + mainThread.Enqueue(() => + { + try + { + const string line = + "Companion (offline): Stay safe, preserve resources, and observe before acting."; + var applied = new AppliedAction(true, line); + var occurrenceTick = completedAttempt is null + ? (long)Time.frameCount + : Math.Max( + (long)Time.frameCount, + completedAttempt.ProposeTick); + var pending = ObservePending( + sessionId, operationId, occurrenceTick, applied); + Action effect = () => + { + NpcActionReady?.Invoke("wait", line); + Logger.LogMessage(line); }; - Logger.LogMessage(line); - NpcActionReady?.Invoke(actionId, line); - completion.SetResult(new AppliedAction(true, line)); + if (!PersistAuthoritativeTransaction( + operationId, + applied, + pending, + completedAttempt, + effect)) + throw new InvalidOperationException("Offline game transaction was not persisted"); + completion.SetResult(null); } - catch (Exception) + catch (Exception exception) { - completion.SetResult(new AppliedAction(false, "The game could not apply the proposal.")); + completion.SetException(exception); } }); return completion.Task; } + private static PendingOutcome CommitPending( + string currentSessionId, + string operationId, + string proposalId, + long occurrenceTick, + AppliedAction applied) + { + var commit = new Dictionary + { + ["protocol_version"] = RinClient.ProtocolVersion, + ["session_id"] = currentSessionId, + ["request_id"] = "commit." + operationId, + ["proposal_id"] = proposalId, + ["event_id"] = "outcome." + operationId, + ["tick"] = occurrenceTick, + ["accepted"] = applied.Accepted, + ["outcome"] = applied.Outcome, + ["tags"] = new[] { "bepinex-example", "conversation" }, + }; + return new PendingOutcome( + currentSessionId, + OutcomeKind.Commit, + commit, + SafeObserveRequest(currentSessionId, operationId, occurrenceTick, applied)); + } + + private static PendingOutcome ObservePending( + string currentSessionId, + string operationId, + long occurrenceTick, + AppliedAction applied) + { + var observe = SafeObserveRequest( + currentSessionId, operationId, occurrenceTick, applied); + return new PendingOutcome( + currentSessionId, + OutcomeKind.Observe, + observe, + observe); + } + + private static Dictionary SafeObserveRequest( + string currentSessionId, + string operationId, + long occurrenceTick, + AppliedAction applied) => new() + { + // Degraded reporting is memory plus one absolute fact; it never + // duplicates relative goal/progress deltas. + ["protocol_version"] = RinClient.ProtocolVersion, + ["session_id"] = currentSessionId, + ["request_id"] = "fallback.observe." + operationId, + // Commit and degraded Observe describe one occurrence and therefore + // deliberately share the same idempotency event ID. + ["event_id"] = "outcome." + operationId, + ["tick"] = occurrenceTick, + ["observer_ids"] = new[] { ActorId }, + ["source"] = "bepinex-example", + ["kind"] = "action_outcome", + ["summary"] = applied.Outcome, + ["tags"] = new[] { "outcome", "degraded-report" }, + ["importance"] = 3, + ["facts"] = new object[] + { + new Dictionary + { + ["subject_id"] = ActorId, + ["predicate"] = "last_action_outcome", + ["object"] = applied.Accepted ? "accepted" : "rejected", + ["visibility"] = new[] { ActorId }, + ["confidence"] = 100, + }, + }, + }; + + private bool PersistAuthoritativeTransaction( + string operationId, + AppliedAction result, + PendingOutcome pending, + ProposalAttempt? completedAttempt, + Action applyGameState) + { + // PRODUCTION PERSISTENCE HOOK: replace this body with one fallible, + // atomic game-save transaction. The game mutation, applied marker, + // complete Commit plus degraded-Observe Outbox entry, exact Create + // request, sequence, and deletion of the completed Proposal attempt + // must commit or roll back together. This demo removes its + // marker/outbox and retains the attempt when a subscriber throws, but + // only a real game transaction can reverse a subscriber's partial + // world mutation. + lock (persistenceLock) + { + if (appliedOperations.ContainsKey(operationId)) return true; + if (completedAttempt is not null + && !ReferenceEquals(proposalAttempt, completedAttempt)) + return false; + appliedOperations[operationId] = result; + outcomeOutbox[operationId] = pending; + try + { + applyGameState(); + if (completedAttempt is not null) proposalAttempt = null; + return true; + } + catch + { + if (outcomeOutbox.TryGetValue(operationId, out var storedPending) + && ReferenceEquals(storedPending, pending)) + outcomeOutbox.TryRemove(operationId, out _); + if (appliedOperations.TryGetValue(operationId, out var storedResult) + && ReferenceEquals(storedResult, result)) + appliedOperations.TryRemove(operationId, out _); + throw; + } + } + } + + private bool PersistOutboxConversion( + string operationId, + PendingOutcome original, + PendingOutcome converted) + { + // PRODUCTION PERSISTENCE HOOK: atomically replace only an explicitly + // unrecoverable Commit with its pre-recorded Observe; return false on + // save failure so the exact Commit remains. + return outcomeOutbox.TryGetValue(operationId, out var stored) + && ReferenceEquals(stored, original) + && converted.Kind == OutcomeKind.Observe; + } + + private bool PersistOutboxAcknowledgement( + string operationId, + PendingOutcome pending) + { + // PRODUCTION PERSISTENCE HOOK: durably delete the acknowledged entry, + // returning false on save failure. In-memory eviction happens later. + return outcomeOutbox.TryGetValue(operationId, out var stored) + && ReferenceEquals(stored, pending); + } + + private static FreshnessDecision ProposalFreshness( + JsonElement state, + JsonElement proposal) + { + var proposalId = RequiredString(proposal, "id"); + if (!state.TryGetProperty("proposals", out var proposals) + || proposals.ValueKind != JsonValueKind.Object + || !proposals.TryGetProperty(proposalId, out var retained) + || retained.ValueKind != JsonValueKind.Object + || !retained.TryGetProperty("status", out var status) + || status.ValueKind != JsonValueKind.String + || !string.Equals(status.GetString(), "pending", StringComparison.Ordinal)) + return FreshnessDecision.Stale; + + var basedOnWorld = OptionalInt64(proposal, "based_on_world_revision", 0); + if (basedOnWorld > 0) + return OptionalInt64(state, "world_revision", -1) == basedOnWorld + ? FreshnessDecision.Fresh + : FreshnessDecision.Stale; + return OptionalInt64(state, "revision", -1) + == OptionalInt64(proposal, "created_revision", -2) + ? FreshnessDecision.Fresh + : FreshnessDecision.Stale; + } + + private void EnqueueIntegrationFailure(string code, bool actionHandled) + { + if (actionHandled) + { + EnqueueLog( + "A handled game action remains durably queued; no new turn may start (" + code + ").", + error: true); + return; + } + EnqueueLog( + "Rin integration failed before a game action was applied (" + code + ").", + error: true); + } + private void EnqueueLog(string message, bool error = false) { mainThread.Enqueue(() => @@ -277,7 +933,10 @@ private void EnqueueLog(string message, bool error = false) }); } - private static Dictionary ActionSpec(string id, string kind, string description) => new() + private static Dictionary ActionSpec( + string id, + string kind, + string description) => new() { ["id"] = id, ["kind"] = kind, @@ -286,18 +945,54 @@ private void EnqueueLog(string message, bool error = false) private static JsonElement RequiredObject(JsonElement parent, string name) { - if (!parent.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Object) - throw new RinProtocolException("invalid_response", "Rin response is missing " + name); + if (!parent.TryGetProperty(name, out var value) + || value.ValueKind != JsonValueKind.Object) + throw new RinProtocolException( + "invalid_response", "Rin response is missing " + name); return value; } private static string RequiredString(JsonElement parent, string name) { - if (!parent.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.String) - throw new RinProtocolException("invalid_response", "Rin response is missing " + name); + if (!parent.TryGetProperty(name, out var value) + || value.ValueKind != JsonValueKind.String) + throw new RinProtocolException( + "invalid_response", "Rin response is missing " + name); return value.GetString() ?? string.Empty; } + private static long OptionalInt64( + JsonElement parent, + string name, + long fallback) + { + return parent.TryGetProperty(name, out var value) + && value.ValueKind == JsonValueKind.Number + && value.TryGetInt64(out var number) + ? number + : fallback; + } + + private static string ExceptionCode(Exception exception) + { + return exception is RinException rinException + ? rinException.Code + : "integration_failed"; + } + + private enum OutcomeKind + { + Commit, + Observe, + } + + private enum FreshnessDecision + { + Fresh, + Stale, + Unavailable, + } + private sealed class AppliedAction { public AppliedAction(bool accepted, string outcome) @@ -309,4 +1004,83 @@ public AppliedAction(bool accepted, string outcome) public bool Accepted { get; } public string Outcome { get; } } + + private sealed class ProposalAttempt + { + public ProposalAttempt( + string currentSessionId, + string operationId, + long currentSequence, + string requestId, + long proposeTick, + Dictionary observeRequest, + Dictionary proposeRequest) + { + SessionId = currentSessionId; + OperationId = operationId; + Sequence = currentSequence; + RequestId = requestId; + ProposeTick = proposeTick; + ObserveRequest = observeRequest; + ProposeRequest = proposeRequest; + } + + public string SessionId { get; } + public string OperationId { get; } + public long Sequence { get; } + public string RequestId { get; } + public long ProposeTick { get; } + public Dictionary ObserveRequest { get; } + public Dictionary ProposeRequest { get; } + public string JobId { get; set; } = string.Empty; + } + + private sealed class ProposalResolution + { + private ProposalResolution( + JsonElement proposal, + bool useAuthoredFallback, + string reason) + { + Proposal = proposal; + UseAuthoredFallback = useAuthoredFallback; + Reason = reason; + } + + public JsonElement Proposal { get; } + public bool UseAuthoredFallback { get; } + public string Reason { get; } + + public static ProposalResolution FromProposal(JsonElement proposal) => + new(proposal, false, string.Empty); + + public static ProposalResolution AuthoredFallback(string reason) => + new(default, true, reason); + } + + private sealed class PendingOutcome + { + public PendingOutcome( + string currentSessionId, + OutcomeKind kind, + Dictionary request, + Dictionary degradedObserveRequest) + { + SessionId = currentSessionId; + Kind = kind; + Request = request; + DegradedObserveRequest = degradedObserveRequest; + } + + public string SessionId { get; } + public OutcomeKind Kind { get; } + public Dictionary Request { get; } + public Dictionary DegradedObserveRequest { get; } + + public PendingOutcome AsDegradedObserve() => new( + SessionId, + OutcomeKind.Observe, + DegradedObserveRequest, + DegradedObserveRequest); + } } diff --git a/examples/mods/bepinex-rin-npc/README.md b/examples/mods/bepinex-rin-npc/README.md index 17d760c..45a6503 100644 --- a/examples/mods/bepinex-rin-npc/README.md +++ b/examples/mods/bepinex-rin-npc/README.md @@ -17,10 +17,35 @@ This source overlay targets BepInEx 6 on a modern Unity/.NET runtime. target game's actual dialogue or interaction hook. `Update` only drains a bounded main-thread queue and detects the optional demo -key. HTTP runs asynchronously. The plugin validates `talk`, `wait`, or -`refuse`, invokes `NpcActionReady` on Unity's main thread, and commits only -after that application step. A real game-specific plugin should subscribe to -the event and map those IDs to its own NPC APIs. +key. HTTP runs asynchronously. The plugin opts into `outcome-reporting-v1` and +re-reads Session immediately before apply. The proposal must still be +`pending`, with a matching world revision (or creation revision for a +non-world proposal); otherwise the game rejects it without an effect. The +plugin validates `talk`, `wait`, or `refuse`, invokes `NpcActionReady` on +Unity's main thread, and captures `Time.frameCount` at the actual accept/reject. +A real game-specific plugin should map those IDs to its own NPC APIs. + +The complete Create payload, request ID, and seed remain unchanged across +retries. If Rin is unavailable before any online proposal exists, the plugin +may run one explicit game-authored fallback. State failures after an online +proposal fail closed. Before submitting, the plugin retains the complete +Propose request and, after `202`, its Job ID. An unresolved attempt is resumed +on the next interaction without advancing the sequence or choosing a fallback; +it is removed only in the game transaction that stores the effect, applied +marker, and Outbox entry. Either a retained attempt or an Outbox entry blocks +every new turn. + +This source-only sample stores applied operations and Outbox entries in memory. +Each Commit also stores a safe Observe fallback containing only memory and an +absolute fact. Temporary errors retain the exact Commit; only explicit terminal +errors such as `unknown_proposal` atomically convert it to Observe. Durable +ACK/delete must succeed before eviction. Replace the marked hooks with one +fallible game-save transaction covering the effect, marker, both report +payloads, retained Create/Propose requests, optional Job ID, and +session/sequence state. In particular, an `NpcActionReady` +subscriber exception must roll the transaction back and leave no accepted +marker or Outbox entry; only the target game's real transaction can also undo a +subscriber's partial world mutation. Official plugin tutorial: https://docs.bepinex.dev/articles/dev_guide/plugin_tutorial/index.html Configuration guide: https://docs.bepinex.dev/articles/dev_guide/plugin_tutorial/4_configuration.html diff --git a/examples/mods/bepinex-rin-npc/README.zh-CN.md b/examples/mods/bepinex-rin-npc/README.zh-CN.md index 24b926d..11f2b59 100644 --- a/examples/mods/bepinex-rin-npc/README.zh-CN.md +++ b/examples/mods/bepinex-rin-npc/README.zh-CN.md @@ -17,9 +17,30 @@ `RequestNpcTurn`。 `Update` 只排空有界主线程队列并检测可选 Demo Key;HTTP 异步运行。插件 -验证 `talk`、`wait` 或 `refuse`,在 Unity 主线程调用 `NpcActionReady`, -并且只在应用后 Commit。真实游戏专用插件应订阅该事件,把这些 ID 映射到 -自己的 NPC API。 +启用 `outcome-reporting-v1`,并在应用前重新读取 Session。Proposal 必须仍 +是 `pending`,而且 World Revision(非世界 Proposal 则为创建 Revision) +仍然匹配,否则游戏不执行效果而报告 Rejected。插件验证 `talk`、`wait` +或 `refuse`,在 Unity 主线程调用 `NpcActionReady`,并在实际 Accept/Reject +时读取 `Time.frameCount`。真实游戏插件应把这些 ID 映射到自己的 NPC API。 + +完整 Create Payload、Request ID 和 Seed 在所有重试间保持不变。只有 Rin +尚未生成在线 Proposal 的冷启动不可用场景,插件才执行一个明确由游戏编写 +的 Fallback;在线 Proposal 后的 State 失败必须 Fail Closed。只要 Outbox +仍有 Entry,就禁止开始新 Turn。插件会在提交前保留完整 Propose Request, +并在收到 `202` 后立即保存 Job ID;未决 Attempt 会在下一次交互中用同一 +身份恢复,不增长 Sequence,也不选择 Fallback。只有游戏效果、Applied +Marker 与 Outbox 在同一事务中落盘时才移除 Attempt;未决 Attempt 或 Outbox +都会阻止所有新 Turn。 + +本源码示例只在内存保存 Applied Operation 与 Outbox。每条 Commit 也保存 +一个只含 Memory 与绝对 Fact 的安全 Observe 降级载荷;临时错误保留原 +Commit,只有 `unknown_proposal` 等明确终态错误才原子转换。Durable +ACK/Delete 成功后才能 Evict。生产接入应把标记 Hook 替换为可失败的游戏 +保存事务,同时包住效果、Marker、两份报告载荷、保留的 Create/Propose +Request、可选 Job ID 以及 Session/Sequence 状态。 +尤其是 `NpcActionReady` Subscriber 抛错时必须回滚,不能留下 Accepted +Marker 或 Outbox;只有目标游戏的真实事务还能撤销 Subscriber 已部分写入 +的世界状态。 官方插件教程:https://docs.bepinex.dev/articles/dev_guide/plugin_tutorial/index.html diff --git a/examples/mods/fabric-rin-npc/README.md b/examples/mods/fabric-rin-npc/README.md index 6236ff1..69f5715 100644 --- a/examples/mods/fabric-rin-npc/README.md +++ b/examples/mods/fabric-rin-npc/README.md @@ -15,12 +15,36 @@ build plugin stay on compatible versions. 4. Start Rin and set optional `RIN_URL` / `RIN_TOKEN` environment variables. 5. Run the server and enter `/rin-npc ask` as a player. -The command creates an isolated sample session, observes the interaction, -submits an asynchronous proposal job, validates one of three action IDs, then -uses `MinecraftServer.execute` to apply it on the server thread. The result is -committed only after application. Replace the chat-only `switch` with your own -NPC API; do not let model text directly invoke commands, item grants, or world -edits. +The command creates an isolated `outcome-reporting-v1` session, observes the +interaction, and submits an asynchronous proposal job. Immediately before +apply it reads Session state again: the proposal must still be `pending`, and +its world revision (or, for a non-world proposal, creation revision) must still +match. Stale proposals are rejected without a game effect. The allowlisted +result is applied with `MinecraftServer.execute`, and its actual server tick is +captured at that accept/reject decision. Replace the chat-only `switch` with +your own NPC API; never let model text directly invoke commands, item grants, +or world edits. + +The complete Create payload (including request ID and seed) stays stable across +ambiguous retries. If Rin is unavailable before any online proposal exists, +the game may apply one explicit authored offline fallback. Once a proposal +exists, State/read errors fail closed. A retained Outbox entry always blocks a +new turn until it can be flushed. Before submitting, the mod also retains the +complete Propose request and, after `202`, its Job ID. An unresolved attempt is +resumed on the next command without advancing the sequence or choosing a +fallback; it is removed only in the game transaction that stores the effect, +applied marker, and Outbox entry. Either retained state blocks a new turn. + +This source-only sample keeps applied operations and the Outbox in memory. A +Commit entry also contains a safe Observe fallback made only of memory and an +absolute fact. Temporary Commit errors retain the exact Commit; only explicit +terminal errors such as `unknown_proposal` atomically convert it to Observe. +Durable ACK/delete must succeed before eviction. Replace all marked persistence +hooks with one fallible authoritative world/player-data transaction covering +the game effect, applied marker, both report payloads, retained Create/Propose +requests, optional Job ID, and session/sequence state. The demo removes +marker/outbox state when its effect callback throws, but only a real game save +transaction can roll back a partial world mutation. Reference template: https://github.com/FabricMC/fabric-example-mod Project structure: https://docs.fabricmc.net/develop/getting-started/project-structure diff --git a/examples/mods/fabric-rin-npc/README.zh-CN.md b/examples/mods/fabric-rin-npc/README.zh-CN.md index d8a0c90..c337371 100644 --- a/examples/mods/fabric-rin-npc/README.zh-CN.md +++ b/examples/mods/fabric-rin-npc/README.zh-CN.md @@ -14,10 +14,31 @@ 4. 启动 Rin,并按需设置 `RIN_URL` / `RIN_TOKEN` 环境变量。 5. 启动服务器,以玩家身份输入 `/rin-npc ask`。 -该命令创建隔离的示例 Session,观察交互,提交异步 Proposal Job,验证三个 -Action ID 之一,再使用 `MinecraftServer.execute` 在服务器线程应用。只有 -应用后才 Commit。应把只发聊天的 `switch` 替换为自己的 NPC API;不要让 -模型文本直接调用命令、发放 Item 或修改世界。 +该命令创建隔离且启用 `outcome-reporting-v1` 的 Session,观察交互并提交 +异步 Proposal Job。应用前会重新读取 Session:Proposal 必须仍是 `pending`, +而且 World Revision(非世界 Proposal 则为创建 Revision)必须仍然匹配。 +过期 Proposal 不产生游戏效果,只报告 Rejected。允许的结果通过 +`MinecraftServer.execute` 在服务器线程应用,并在实际 Accept/Reject 时 +读取服务器 Tick。应把只发聊天的 `switch` 替换为自己的 NPC API;绝不能 +让模型文本直接调用命令、发放 Item 或修改世界。 + +完整 Create Payload(包括 Request ID 与 Seed)在模糊失败后的重试中保持 +不变。只有在 Rin 尚未产生任何在线 Proposal 的冷启动不可用场景,游戏才 +执行一个明确编写的 Offline Fallback;一旦已有 Proposal,State 读取失败 +必须 Fail Closed。Outbox 仍有待处理 Entry 时,不得开始新 Turn。Mod 还会 +在提交前保留完整 Propose Request,并在收到 `202` 后立即保存 Job ID;未决 +Attempt 会在下一次命令中用同一身份恢复,不增长 Sequence,也不选择 +Fallback。只有游戏效果、Applied Marker 与 Outbox 在同一事务中落盘时才 +移除 Attempt;任一种保留状态都会阻止新 Turn。 + +本源码示例只在内存中保存 Applied Operation 与 Outbox。每条 Commit 同时 +保存一个只含 Memory 与绝对 Fact 的安全 Observe 降级载荷;临时错误保留 +原 Commit,只有 `unknown_proposal` 等明确终态错误才原子转换为 Observe。 +必须在 Durable ACK/Delete 成功后才能 Evict。生产接入应把所有标记 Hook +替换为可失败的权威世界/玩家数据事务,同时包住游戏效果、Applied Marker、 +两份报告载荷、保留的 Create/Propose Request、可选 Job ID 及 +Session/Sequence 状态。示例会在效果 Callback 抛错时移除 Marker/Outbox, +但只有真实游戏保存事务才能回滚已经部分写入的世界效果。 参考模板:https://github.com/FabricMC/fabric-example-mod diff --git a/examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/RinNpcMod.java b/examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/RinNpcMod.java index b7744d2..c78ea18 100644 --- a/examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/RinNpcMod.java +++ b/examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/RinNpcMod.java @@ -2,8 +2,10 @@ import com.google.gson.Gson; import com.mojang.brigadier.Command; +import io.github.sunrioa.rin.RinApiException; import io.github.sunrioa.rin.RinClient; import io.github.sunrioa.rin.RinException; +import io.github.sunrioa.rin.RinProtocolException; import net.fabricmc.api.ModInitializer; import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; import net.minecraft.server.MinecraftServer; @@ -26,12 +28,21 @@ public final class RinNpcMod implements ModInitializer { private static final String ACTOR_ID = "npc.rin.guide"; + private static final int MAX_PROPOSAL_POSTS_PER_ENTRY = 2; private static final Set ALLOWED_ACTIONS = Set.of("talk", "wait", "refuse"); + private static final Set TERMINAL_COMMIT_ERRORS = + Set.of("session_not_found", "unknown_proposal", "proposal_resolved"); + private static final Set AMBIGUOUS_PROPOSAL_ERRORS = + Set.of("job_cancel_unconfirmed", "job_outcome_unknown", + "job_timeout", "proposal_outcome_unknown"); - private final String runId = UUID.randomUUID().toString().substring(0, 12); + private final String runId = UUID.randomUUID().toString(); private final AtomicLong sequence = new AtomicLong(); - private final Map> sessions = new ConcurrentHashMap<>(); + private final Map sessions = new ConcurrentHashMap<>(); + private final Map appliedOperations = new ConcurrentHashMap<>(); + private final Map outcomeOutbox = new ConcurrentHashMap<>(); private final Set activePlayers = ConcurrentHashMap.newKeySet(); + private final Object persistenceLock = new Object(); private final RinClient rin = new RinClient( System.getenv().getOrDefault("RIN_URL", RinClient.DEFAULT_BASE_URL), System.getenv().getOrDefault("RIN_TOKEN", ""), @@ -65,135 +76,821 @@ private void requestTurn(ServerCommandSource source) { return; } String sessionId = "fabric." + runId + "." + playerId; - long turn = sequence.incrementAndGet(); - long tick = server.getTicks(); + SessionRegistration registration = sessions.computeIfAbsent( + sessionId, + ignored -> newSessionRegistration(sessionId, player.getName().getString())); source.sendFeedback(() -> Text.literal("The Rin guide is considering the situation..."), false); - ensureSession(sessionId, player.getName().getString(), turn) - .thenCompose(ignored -> rin.observe(mapOf( - "protocol_version", RinClient.PROTOCOL_VERSION, - "session_id", sessionId, - "request_id", "observe." + turn, - "event_id", "event." + turn, - "tick", tick, - "observer_ids", List.of(ACTOR_ID), - "source", "fabric-example", - "kind", "dialogue", - "summary", "The player asked the guide what to do next.", - "tags", List.of("conversation", "player-request"), - "importance", 3))) - .thenCompose(ignored -> rin.submitProposalJob(mapOf( - "protocol_version", RinClient.PROTOCOL_VERSION, - "session_id", sessionId, - "request_id", "propose." + turn, - "actor_id", ACTOR_ID, - "tick", tick + 1, - "intent", "Choose one bounded response to the player.", - "tags", List.of("conversation"), - "candidate_actions", List.of( - mapOf("id", "talk", "kind", "dialogue", "description", "offer one concrete hint"), - mapOf("id", "wait", "kind", "wait", "description", "ask the player to observe first"), - mapOf("id", "refuse", "kind", "refuse", "description", "decline an unsafe request"))))) - .thenCompose(job -> rin.waitForProposal(text(job, "job_id"))) - .thenCompose(job -> applyAndCommit(server, playerId, sessionId, turn, tick + 2, job)) - .thenAccept(ignored -> server.execute(() -> { - ServerPlayerEntity current = server.getPlayerManager().getPlayer(playerId); - if (current != null) current.sendMessage(Text.literal("Rin turn committed."), false); - })) + ensureSession(registration) + .handle((ignored, createError) -> { + if (createError == null) { + return runOnlineTurn(server, playerId, registration); + } + // Authored fallback is permitted only before Rin has ever + // supplied a proposal. A retained report must be flushed + // first, and an unresolved attempt must retain its exact + // identity, so an outage cannot start another turn. + if (hasPendingOutcome(sessionId) + || retainedProposalAttempt(registration) != null) { + return CompletableFuture.failedFuture(unwrap(createError)); + } + long turn = sequence.incrementAndGet(); + return applyAuthoredOfflineFallback(server, playerId, registration, turn); + }) + .thenCompose(future -> future) .exceptionally(error -> { + invalidateSessionIfNotFound(sessionId, error); String code = safeCode(error); + boolean blockedByDurableState = + hasPendingOutcome(sessionId) + || retainedProposalAttempt(registration) != null; + String message = blockedByDurableState + ? "A durable outcome or unresolved proposal blocks a new turn: " + code + : "Rin request failed before applying a game action: " + code; server.execute(() -> { ServerPlayerEntity current = server.getPlayerManager().getPlayer(playerId); - if (current != null) current.sendMessage(Text.literal("Rin request failed: " + code), false); + if (current != null) current.sendMessage(Text.literal(message), false); }); return null; }) .whenComplete((ignored, error) -> activePlayers.remove(playerId)); } - private CompletableFuture ensureSession(String sessionId, String playerName, long turn) { - return sessions.computeIfAbsent(sessionId, key -> { - CompletableFuture created = rin.createSession(mapOf( - "protocol_version", RinClient.PROTOCOL_VERSION, - "request_id", "create." + turn, - "session_id", sessionId, - "binding", mapOf( - "game_id", "minecraft-fabric", - "content_id", "rin-npc-example", - "content_version", "0.1.0", - "content_hash", "sha256:" + "0".repeat(64)), - "seed", turn, - "actors", List.of(mapOf( - "id", ACTOR_ID, - "kind", "npc", - "display_name", "Rin Guide", - "traits", List.of("observant", "careful"), - "boundaries", List.of(mapOf( - "id", "boundary.no-griefing", - "description", "Never suggest griefing or bypassing server rules.", - "trigger_tags", List.of("unsafe"), - "response", "refuse")), - "goals", List.of(mapOf( - "id", "goal.help-player", - "description", "Help " + playerName + " make one informed choice.", - "priority", 4, - "preferred_actions", List.of("talk"), - "progress", 0, - "target_progress", 3, - "status", "active")), - "think_every_ticks", 20, - "enabled", true)))) + private CompletableFuture runOnlineTurn( + MinecraftServer server, + UUID playerId, + SessionRegistration registration) { + String sessionId = registration.sessionId; + return flushOutcomeOutbox(sessionId) + .thenCompose(ignored -> { + ProposalAttempt attempt = retainNewProposalAttempt( + registration, server.getTicks()); + // Replaying the exact Observe closes an ambiguous response + // before resuming this same persisted Propose identity. + return rin.observe(attempt.observeRequest) + .thenCompose(observed -> resolveProposalAttempt( + registration, + attempt, + MAX_PROPOSAL_POSTS_PER_ENTRY)); + }) + .thenCompose(resolution -> { + if (resolution.useAuthoredFallback) { + if ("session_not_found".equals(resolution.reason)) { + invalidateSession(sessionId); + } + return applyRetainedAuthoredFallback( + server, + playerId, + registration, + resolution.attempt); + } + // A temporary State failure fails closed. It must never + // turn an already-online model proposal into fallback. + return revalidateApplyAndReport( + server, + playerId, + registration, + resolution.attempt, + resolution.proposal); + }) + .thenRun(() -> server.execute(() -> { + ServerPlayerEntity current = server.getPlayerManager().getPlayer(playerId); + if (current != null) current.sendMessage(Text.literal("Rin outcome acknowledged."), false); + })); + } + + private ProposalAttempt retainNewProposalAttempt( + SessionRegistration registration, + long observedTick) { + synchronized (persistenceLock) { + if (registration.proposalAttempt != null) { + return registration.proposalAttempt; + } + long turn = sequence.updateAndGet(Math::incrementExact); + String operationId = runId + "." + turn; + String requestId = "propose." + operationId; + long proposeTick = Math.incrementExact(observedTick); + ProposalAttempt retained = new ProposalAttempt( + registration.sessionId, + operationId, + turn, + requestId, + proposeTick, + mapOf( + "protocol_version", RinClient.PROTOCOL_VERSION, + "session_id", registration.sessionId, + "request_id", "observe." + operationId, + "event_id", "event." + operationId, + "tick", observedTick, + "observer_ids", List.of(ACTOR_ID), + "source", "fabric-example", + "kind", "dialogue", + "summary", "The player asked the guide what to do next.", + "tags", List.of("conversation", "player-request"), + "importance", 3), + mapOf( + "protocol_version", RinClient.PROTOCOL_VERSION, + "session_id", registration.sessionId, + "request_id", requestId, + "actor_id", ACTOR_ID, + "tick", proposeTick, + "intent", "Choose one bounded response to the player.", + "tags", List.of("conversation"), + "candidate_actions", List.of( + mapOf("id", "talk", "kind", "dialogue", + "description", "offer one concrete hint"), + mapOf("id", "wait", "kind", "wait", + "description", "ask the player to observe first"), + mapOf("id", "refuse", "kind", "refuse", + "description", "decline an unsafe request")))); + + // PRODUCTION PERSISTENCE HOOK: store the complete Observe and + // Propose payloads, operation ID, sequence, and empty optional job + // ID in player/world data before the first proposal POST. + registration.proposalAttempt = retained; + return retained; + } + } + + private ProposalAttempt retainedProposalAttempt(SessionRegistration registration) { + synchronized (persistenceLock) { + return registration.proposalAttempt; + } + } + + private CompletableFuture resolveProposalAttempt( + SessionRegistration registration, + ProposalAttempt attempt, + int remainingPosts) { + String jobId = retainedProposalJobId(registration, attempt); + if (jobId.isEmpty()) { + return repostProposalAttempt(registration, attempt, remainingPosts); + } + return waitForRetainedProposal( + registration, attempt, jobId, remainingPosts); + } + + private CompletableFuture repostProposalAttempt( + SessionRegistration registration, + ProposalAttempt attempt, + int remainingPosts) { + if (remainingPosts <= 0) { + return CompletableFuture.failedFuture(unknownProposalOutcome(null)); + } + return rin.submitProposalJob(attempt.proposeRequest) + .thenApply(queued -> { + String jobId = text(queued, "job_id"); + if (jobId.isEmpty()) { + throw new IllegalStateException( + "Rin response is missing proposal job_id"); + } + // Persist the 202 handle before any GET/wait begins. + persistProposalJobId(registration, attempt, jobId); + return jobId; + }) + .thenCompose(jobId -> waitForRetainedProposal( + registration, + attempt, + jobId, + remainingPosts - 1)); + } + + private CompletableFuture waitForRetainedProposal( + SessionRegistration registration, + ProposalAttempt attempt, + String jobId, + int remainingPosts) { + return inspectAndWaitForRetainedProposal(attempt, jobId) + .handle((resolution, error) -> { + if (error == null) { + return CompletableFuture.completedFuture(resolution); + } + + Throwable cause = unwrap(error); + String code = safeCode(cause); + if (shouldRepostProposal(code)) { + // Job retention expired, or the durable Proposal result + // was unknown. Drop only the lookup handle and boundedly + // POST the exact same request_id/payload again. + persistProposalJobId(registration, attempt, ""); + if (remainingPosts <= 0) { + return CompletableFuture.failedFuture( + unknownProposalOutcome(cause)); + } + return repostProposalAttempt( + registration, attempt, remainingPosts); + } + if (isConfirmedSafeTerminal(cause)) { + return CompletableFuture.completedFuture( + ProposalResolution.authoredFallback( + attempt, code)); + } + return CompletableFuture.failedFuture(cause); + }) + .thenCompose(future -> future); + } + + private CompletableFuture inspectAndWaitForRetainedProposal( + ProposalAttempt attempt, + String jobId) { + // Validate the immutable Job envelope before SDK polling. If it later + // reports a terminal error, that terminal belongs to this exact + // retained session/request/job identity. + return rin.getProposalJob(jobId) + .thenCompose(currentJob -> { + validateJobIdentity(attempt, jobId, currentJob); + String status = text(currentJob, "status"); + if ("succeeded".equals(status)) { + return CompletableFuture.completedFuture( + ProposalResolution.fromProposal( + attempt, + validateProposalIdentity( + attempt, jobId, currentJob))); + } + if (Set.of("failed", "stale", "canceled").contains(status)) { + return CompletableFuture.failedFuture( + terminalJobError(currentJob, status)); + } + if (!"queued".equals(status) && !"running".equals(status)) { + return CompletableFuture.failedFuture( + new RinProtocolException( + "invalid_job", + "Rin returned an unknown proposal Job status")); + } + return rin.waitForProposal(jobId) + .thenApply(job -> ProposalResolution.fromProposal( + attempt, + validateProposalIdentity( + attempt, jobId, job))); + }); + } + + private String retainedProposalJobId( + SessionRegistration registration, + ProposalAttempt attempt) { + synchronized (persistenceLock) { + if (registration.proposalAttempt != attempt) { + throw new IllegalStateException( + "proposal attempt changed while resolving its job"); + } + return attempt.jobId; + } + } + + private void persistProposalJobId( + SessionRegistration registration, + ProposalAttempt attempt, + String jobId) { + synchronized (persistenceLock) { + if (registration.proposalAttempt != attempt) { + throw new IllegalStateException( + "proposal attempt changed before its job ID was persisted"); + } + // PRODUCTION PERSISTENCE HOOK: atomically update the optional job + // ID immediately after a 202 and before the first GET. + attempt.jobId = jobId; + } + } + + private static boolean shouldRepostProposal(String code) { + return "job_not_found".equals(code) + || "proposal_outcome_unknown".equals(code); + } + + private static boolean isConfirmedSafeTerminal(Throwable error) { + Throwable cause = unwrap(error); + return cause instanceof RinApiException apiError + && apiError.status() == 0 + && !AMBIGUOUS_PROPOSAL_ERRORS.contains(apiError.code()); + } + + private static RinApiException unknownProposalOutcome(Throwable cause) { + String message = "Proposal outcome remains unknown after bounded same-request retries"; + if (cause != null && cause.getMessage() != null) { + message += ": " + cause.getMessage(); + } + return new RinApiException( + "proposal_outcome_unknown", message, 0, ""); + } + + private static Map validateProposalIdentity( + ProposalAttempt attempt, + String expectedJobId, + Map job) { + validateJobIdentity(attempt, expectedJobId, job); + + Map proposal = object(job.get("proposal")); + if (text(proposal, "id").isEmpty() + || !attempt.sessionId.equals(text(proposal, "session_id")) + || !attempt.requestId.equals(text(proposal, "request_id")) + || !ACTOR_ID.equals(text(proposal, "actor_id")) + || integer(proposal.get("tick"), Long.MIN_VALUE) + != attempt.proposeTick) { + throw new RinProtocolException( + "proposal_identity_mismatch", + "Rin returned a Proposal for a different retained proposal attempt"); + } + return proposal; + } + + private static void validateJobIdentity( + ProposalAttempt attempt, + String expectedJobId, + Map job) { + if (!expectedJobId.equals(text(job, "job_id")) + || !attempt.sessionId.equals(text(job, "session_id")) + || !attempt.requestId.equals(text(job, "request_id"))) { + throw new RinProtocolException( + "proposal_identity_mismatch", + "Rin returned a Job for a different retained proposal attempt"); + } + } + + private static RinApiException terminalJobError( + Map job, + String status) { + Map detail = object(job.get("error")); + String code = text(detail, "code"); + String message = text(detail, "message"); + return new RinApiException( + code.isEmpty() ? "job_" + status : code, + message.isEmpty() + ? "Rin proposal Job ended as " + status + : message, + 0, + ""); + } + + private void invalidateSessionIfNotFound(String sessionId, Throwable error) { + Throwable current = unwrap(error); + while (current != null) { + if (current instanceof RinException rinError + && "session_not_found".equals(rinError.code())) { + invalidateSession(sessionId); + return; + } + current = current.getCause(); + } + } + + private FreshnessDecision unavailableFreshness( + String sessionId, + Throwable stateError) { + invalidateSessionIfNotFound(sessionId, stateError); + return FreshnessDecision.UNAVAILABLE; + } + + private SessionRegistration newSessionRegistration(String sessionId, String playerName) { + long seed = Integer.toUnsignedLong(sessionId.hashCode()); + Map request = mapOf( + "protocol_version", RinClient.PROTOCOL_VERSION, + "request_id", "create." + sessionId, + "session_id", sessionId, + "binding", mapOf( + "game_id", "minecraft-fabric", + "content_id", "rin-npc-example", + "content_version", "0.1.0", + "content_hash", "sha256:" + "0".repeat(64)), + "seed", seed, + "features", List.of("outcome-reporting-v1"), + "actors", List.of(mapOf( + "id", ACTOR_ID, + "kind", "npc", + "display_name", "Rin Guide", + "traits", List.of("observant", "careful"), + "boundaries", List.of(mapOf( + "id", "boundary.no-griefing", + "description", "Never suggest griefing or bypassing server rules.", + "trigger_tags", List.of("unsafe"), + "response", "refuse")), + "goals", List.of(mapOf( + "id", "goal.help-player", + "description", "Help " + playerName + " make one informed choice.", + "priority", 4, + "preferred_actions", List.of("talk"), + "progress", 0, + "target_progress", 3, + "status", "active")), + "think_every_ticks", 20, + "enabled", true))); + // The entire request, including request_id and seed, is retained and + // reused byte-for-byte semantically after ambiguous create failures. + return new SessionRegistration(sessionId, request); + } + + private CompletableFuture ensureSession(SessionRegistration registration) { + synchronized (registration) { + if (registration.createAttempt != null) return registration.createAttempt; + CompletableFuture attempt = rin.createSession(registration.createRequest) .thenApply(ignored -> null); - created.whenComplete((ignored, error) -> { - if (error != null) sessions.remove(key, created); + registration.createAttempt = attempt; + attempt.whenComplete((ignored, error) -> { + if (error != null) { + synchronized (registration) { + if (registration.createAttempt == attempt) registration.createAttempt = null; + } + } }); - return created; - }); + return attempt; + } } - private CompletableFuture> applyAndCommit( + private void invalidateSession(String sessionId) { + SessionRegistration registration = sessions.get(sessionId); + if (registration == null) return; + synchronized (registration) { + registration.createAttempt = null; + } + } + + private CompletableFuture revalidateApplyAndReport( MinecraftServer server, UUID playerId, - String sessionId, - long turn, - long tick, - Map job) { - Map proposal = object(job.get("proposal")); - Map action = object(proposal.get("action")); - String actionId = text(action, "id"); - String proposalId = text(proposal, "proposal_id"); - CompletableFuture applied = new CompletableFuture<>(); + SessionRegistration registration, + ProposalAttempt attempt, + Map proposal) { + String proposalId = text(proposal, "id"); + if (proposalId.isEmpty()) { + return CompletableFuture.failedFuture( + new IllegalStateException("Rin response is missing proposal.id")); + } + return rin.state(mapOf( + "protocol_version", RinClient.PROTOCOL_VERSION, + "session_id", registration.sessionId)) + .handle((state, stateError) -> applyAndEnqueueOnServerThread( + server, + playerId, + registration, + attempt, + proposal, + stateError == null + ? proposalFreshness(state, proposal) + : unavailableFreshness( + registration.sessionId, + stateError))) + .thenCompose(future -> future) + .thenCompose(ignored -> reportOutcome(attempt.operationId)); + } + + private static FreshnessDecision proposalFreshness( + Map state, + Map proposal) { + String proposalId = text(proposal, "id"); + Map retained = object(object(state.get("proposals")).get(proposalId)); + if (!"pending".equals(text(retained, "status"))) { + return FreshnessDecision.STALE; + } + + long basedOnWorldRevision = integer(proposal.get("based_on_world_revision"), 0); + if (basedOnWorldRevision > 0) { + return integer(state.get("world_revision"), -1) == basedOnWorldRevision + ? FreshnessDecision.FRESH + : FreshnessDecision.STALE; + } + return integer(state.get("revision"), -1) + == integer(proposal.get("created_revision"), -2) + ? FreshnessDecision.FRESH + : FreshnessDecision.STALE; + } + + private CompletableFuture applyAndEnqueueOnServerThread( + MinecraftServer server, + UUID playerId, + SessionRegistration registration, + ProposalAttempt completedAttempt, + Map proposal, + FreshnessDecision freshness) { + String operationId = completedAttempt.operationId; + AppliedAction stored = appliedOperations.get(operationId); + if (stored != null) return CompletableFuture.completedFuture(stored); + CompletableFuture completion = new CompletableFuture<>(); server.execute(() -> { - ServerPlayerEntity player = server.getPlayerManager().getPlayer(playerId); - if (player == null) { - applied.complete(new AppliedAction(false, "Player left before the proposal could be applied.")); - return; + try { + AppliedAction existing = appliedOperations.get(operationId); + if (existing != null) { + completion.complete(existing); + return; + } + Map action = object(proposal.get("action")); + String actionId = text(action, "id"); + ServerPlayerEntity player = server.getPlayerManager().getPlayer(playerId); + AppliedAction planned; + Runnable gameEffect; + if (freshness == FreshnessDecision.UNAVAILABLE) { + planned = new AppliedAction(false, + "The game rejected the proposal because freshness could not be verified."); + gameEffect = () -> { }; + } else if (freshness == FreshnessDecision.STALE) { + planned = new AppliedAction(false, + "The game rejected a stale proposal before applying it."); + gameEffect = () -> { }; + } else if (player == null) { + planned = new AppliedAction(false, + "The player left before the proposal could be applied."); + gameEffect = () -> { }; + } else if (!ALLOWED_ACTIONS.contains(actionId)) { + planned = new AppliedAction(false, + "The game rejected an action outside its allowlist."); + gameEffect = () -> { }; + } else { + String line = switch (actionId) { + case "talk" -> "Guide: Check the nearby terrain, then choose a route with cover."; + case "wait" -> "Guide: Let us watch one more cycle before acting."; + case "refuse" -> "Guide: I cannot help with an action that breaks the server rules."; + default -> throw new IllegalStateException("allowlist changed during apply"); + }; + planned = new AppliedAction(true, line); + gameEffect = () -> player.sendMessage(Text.literal(line), false); + } + + // The outcome cannot precede either the retained request or + // the returned Proposal, even in the same server tick. + long occurrenceTick = Math.max( + server.getTicks(), + Math.max( + completedAttempt.proposeTick, + integer( + proposal.get("tick"), + completedAttempt.proposeTick))); + PendingOutcome pending = commitPending( + registration.sessionId, + operationId, + text(proposal, "id"), + occurrenceTick, + planned); + if (!persistAuthoritativeTransaction( + registration, + completedAttempt, + operationId, + planned, + pending, + gameEffect)) { + throw new IllegalStateException("authoritative game transaction was not persisted"); + } + completion.complete(planned); + } catch (Throwable error) { + completion.completeExceptionally(error); } - if (!ALLOWED_ACTIONS.contains(actionId)) { - applied.complete(new AppliedAction(false, "The game rejected an action outside its allowlist.")); - return; + }); + return completion; + } + + private CompletableFuture applyAuthoredOfflineFallback( + MinecraftServer server, + UUID playerId, + SessionRegistration registration, + long turn) { + return applyAuthoredFallbackTransaction( + server, + playerId, + registration, + runId + ".offline." + turn, + null); + } + + private CompletableFuture applyRetainedAuthoredFallback( + MinecraftServer server, + UUID playerId, + SessionRegistration registration, + ProposalAttempt completedAttempt) { + return applyAuthoredFallbackTransaction( + server, + playerId, + registration, + completedAttempt.operationId, + completedAttempt) + .thenCompose(ignored -> reportOutcome(completedAttempt.operationId)); + } + + private CompletableFuture applyAuthoredFallbackTransaction( + MinecraftServer server, + UUID playerId, + SessionRegistration registration, + String operationId, + ProposalAttempt completedAttempt) { + CompletableFuture completion = new CompletableFuture<>(); + server.execute(() -> { + try { + ServerPlayerEntity player = server.getPlayerManager().getPlayer(playerId); + String line = "Guide (offline): Stay safe, preserve your resources, and observe before acting."; + AppliedAction applied; + Runnable effect; + if (player == null) { + applied = new AppliedAction( + false, + "The player left before the authored fallback could be applied."); + effect = () -> { }; + } else { + applied = new AppliedAction(true, line); + effect = () -> player.sendMessage(Text.literal(line), false); + } + long occurrenceTick = completedAttempt == null + ? server.getTicks() + : Math.max( + server.getTicks(), + completedAttempt.proposeTick); + PendingOutcome pending = observePending( + registration.sessionId, + operationId, + occurrenceTick, + applied); + if (!persistAuthoritativeTransaction( + registration, + completedAttempt, + operationId, + applied, + pending, + effect)) { + throw new IllegalStateException("offline game transaction was not persisted"); + } + completion.complete(null); + } catch (Throwable error) { + completion.completeExceptionally(error); } - String line = switch (actionId) { - case "talk" -> "Guide: Check the nearby terrain, then choose a route with cover."; - case "wait" -> "Guide: Let us watch one more cycle before acting."; - case "refuse" -> "Guide: I cannot help with an action that breaks the server rules."; - default -> throw new IllegalStateException("allowlist changed during apply"); - }; - player.sendMessage(Text.literal(line), false); - applied.complete(new AppliedAction(true, line)); }); + return completion; + } - return applied.thenCompose(result -> rin.commit(mapOf( + private PendingOutcome commitPending( + String sessionId, + String operationId, + String proposalId, + long occurrenceTick, + AppliedAction applied) { + Map commitRequest = mapOf( "protocol_version", RinClient.PROTOCOL_VERSION, "session_id", sessionId, - "request_id", "commit." + turn, + "request_id", "commit." + operationId, "proposal_id", proposalId, - "event_id", "outcome." + turn, - "tick", tick, - "accepted", result.accepted(), - "outcome", result.outcome(), - "tags", List.of("fabric-example", "conversation")))); + "event_id", "outcome." + operationId, + "tick", occurrenceTick, + "accepted", applied.accepted, + "outcome", applied.outcome, + "tags", List.of("fabric-example", "conversation")); + return new PendingOutcome( + sessionId, + OutcomeKind.COMMIT, + commitRequest, + safeObserveRequest(sessionId, operationId, occurrenceTick, applied)); + } + + private PendingOutcome observePending( + String sessionId, + String operationId, + long occurrenceTick, + AppliedAction applied) { + Map observe = safeObserveRequest( + sessionId, operationId, occurrenceTick, applied); + return new PendingOutcome(sessionId, OutcomeKind.OBSERVE, observe, observe); + } + + private static Map safeObserveRequest( + String sessionId, + String operationId, + long occurrenceTick, + AppliedAction applied) { + // Degraded reporting is intentionally limited to episodic memory and + // an absolute fact. It never replays relative goal/progress deltas. + return mapOf( + "protocol_version", RinClient.PROTOCOL_VERSION, + "session_id", sessionId, + "request_id", "fallback.observe." + operationId, + // Commit and degraded Observe describe one occurrence, so + // they deliberately share the same idempotency event ID. + "event_id", "outcome." + operationId, + "tick", occurrenceTick, + "observer_ids", List.of(ACTOR_ID), + "source", "fabric-example", + "kind", "action_outcome", + "summary", applied.outcome, + "tags", List.of("outcome", "degraded-report"), + "importance", 3, + "facts", List.of(mapOf( + "subject_id", ACTOR_ID, + "predicate", "last_action_outcome", + "object", applied.accepted ? "accepted" : "rejected", + "visibility", List.of(ACTOR_ID), + "confidence", 100))); + } + + private boolean persistAuthoritativeTransaction( + SessionRegistration registration, + ProposalAttempt completedAttempt, + String operationId, + AppliedAction result, + PendingOutcome pending, + Runnable applyGameState) { + // PRODUCTION PERSISTENCE HOOK: replace this whole body with one + // fallible, atomic world/player-data transaction. The actual game + // mutation, applied marker, complete Commit plus degraded-Observe + // Outbox entry, session request, runId, sequence, and deletion of the + // completed Proposal attempt must commit or roll back together. The + // demo rollback prevents a throwing effect callback from leaving an + // accepted marker/outbox and retains the attempt, but only the real + // game save transaction can roll back an already-mutated world. + synchronized (persistenceLock) { + if (appliedOperations.containsKey(operationId)) return true; + if (completedAttempt != null + && registration.proposalAttempt != completedAttempt) { + return false; + } + appliedOperations.put(operationId, result); + outcomeOutbox.put(operationId, pending); + try { + applyGameState.run(); + if (completedAttempt != null) { + registration.proposalAttempt = null; + } + return true; + } catch (Throwable error) { + outcomeOutbox.remove(operationId, pending); + appliedOperations.remove(operationId, result); + throw error; + } + } + } + + private CompletableFuture flushOutcomeOutbox(String sessionId) { + List operationIds = outcomeOutbox.entrySet().stream() + .filter(entry -> entry.getValue().sessionId.equals(sessionId)) + .map(Map.Entry::getKey) + .sorted() + .toList(); + CompletableFuture retries = CompletableFuture.completedFuture(null); + for (String operationId : operationIds) { + retries = retries.thenCompose(ignored -> reportOutcome(operationId)); + } + return retries; + } + + private CompletableFuture reportOutcome(String operationId) { + PendingOutcome pending = outcomeOutbox.get(operationId); + if (pending == null) return CompletableFuture.completedFuture(null); + if (pending.kind == OutcomeKind.OBSERVE) { + return rin.observe(pending.request) + .thenCompose(ignored -> acknowledgeOutcome(operationId, pending)); + } + return rin.commit(pending.request) + .handle((ignored, error) -> { + if (error == null) return acknowledgeOutcome(operationId, pending); + Throwable cause = unwrap(error); + String code = safeCode(cause); + if (!TERMINAL_COMMIT_ERRORS.contains(code)) { + return CompletableFuture.failedFuture(cause); + } + PendingOutcome converted = pending.asDegradedObserve(); + if (!persistOutboxConversion(operationId, pending, converted)) { + return CompletableFuture.failedFuture( + new IllegalStateException("outbox conversion was not persisted")); + } + outcomeOutbox.replace(operationId, pending, converted); + if ("session_not_found".equals(code)) { + invalidateSession(pending.sessionId); + // Re-create from the exact retained Create request on + // the next entry, then flush this Observe before a turn. + return CompletableFuture.failedFuture(cause); + } + return rin.observe(converted.request) + .thenCompose(result -> acknowledgeOutcome(operationId, converted)); + }) + .thenCompose(future -> future); + } + + private boolean persistOutboxConversion( + String operationId, + PendingOutcome original, + PendingOutcome converted) { + // PRODUCTION PERSISTENCE HOOK: atomically replace the unrecoverable + // Commit with its pre-recorded safe Observe. Return false on any save + // failure; the exact original Commit then remains retryable. + return outcomeOutbox.get(operationId) == original + && converted.kind == OutcomeKind.OBSERVE; + } + + private CompletableFuture acknowledgeOutcome( + String operationId, + PendingOutcome acknowledged) { + // Durable ACK/delete succeeds before the in-memory entry is evicted. + if (!persistOutboxAcknowledgement(operationId, acknowledged)) { + return CompletableFuture.failedFuture( + new IllegalStateException("outbox acknowledgement was not persisted")); + } + outcomeOutbox.remove(operationId, acknowledged); + return CompletableFuture.completedFuture(null); + } + + private boolean persistOutboxAcknowledgement( + String operationId, + PendingOutcome acknowledged) { + // PRODUCTION PERSISTENCE HOOK: atomically persist acknowledged Outbox + // deletion and return false on failure. + return outcomeOutbox.get(operationId) == acknowledged; + } + + private boolean hasPendingOutcome(String sessionId) { + return outcomeOutbox.values().stream() + .anyMatch(pending -> pending.sessionId.equals(sessionId)); } private static Map mapOf(Object... entries) { @@ -219,11 +916,131 @@ private static String text(Map value, String key) { return item instanceof String text ? text : ""; } - private static String safeCode(Throwable error) { + private static long integer(Object value, long fallback) { + if (!(value instanceof Number number)) return fallback; + double checked = number.doubleValue(); + if (!Double.isFinite(checked) || checked != Math.rint(checked)) return fallback; + return number.longValue(); + } + + private static Throwable unwrap(Throwable error) { Throwable cause = error; - while (cause instanceof CompletionException && cause.getCause() != null) cause = cause.getCause(); + while ((cause instanceof CompletionException) + && cause.getCause() != null) cause = cause.getCause(); + return cause; + } + + private static String safeCode(Throwable error) { + Throwable cause = unwrap(error); return cause instanceof RinException rinError ? rinError.code() : "integration_failed"; } - private record AppliedAction(boolean accepted, String outcome) { } + private enum OutcomeKind { COMMIT, OBSERVE } + private enum FreshnessDecision { FRESH, STALE, UNAVAILABLE } + + private static final class SessionRegistration { + private final String sessionId; + private final Map createRequest; + private CompletableFuture createAttempt; + private ProposalAttempt proposalAttempt; + + private SessionRegistration(String sessionId, Map createRequest) { + this.sessionId = sessionId; + this.createRequest = createRequest; + } + } + + private static final class ProposalAttempt { + private final String sessionId; + private final String operationId; + private final long sequence; + private final String requestId; + private final long proposeTick; + private final Map observeRequest; + private final Map proposeRequest; + private String jobId = ""; + + private ProposalAttempt( + String sessionId, + String operationId, + long sequence, + String requestId, + long proposeTick, + Map observeRequest, + Map proposeRequest) { + this.sessionId = sessionId; + this.operationId = operationId; + this.sequence = sequence; + this.requestId = requestId; + this.proposeTick = proposeTick; + this.observeRequest = observeRequest; + this.proposeRequest = proposeRequest; + } + } + + private static final class ProposalResolution { + private final ProposalAttempt attempt; + private final Map proposal; + private final boolean useAuthoredFallback; + private final String reason; + + private ProposalResolution( + ProposalAttempt attempt, + Map proposal, + boolean useAuthoredFallback, + String reason) { + this.attempt = attempt; + this.proposal = proposal; + this.useAuthoredFallback = useAuthoredFallback; + this.reason = reason; + } + + private static ProposalResolution fromProposal( + ProposalAttempt attempt, + Map proposal) { + return new ProposalResolution(attempt, proposal, false, ""); + } + + private static ProposalResolution authoredFallback( + ProposalAttempt attempt, + String reason) { + return new ProposalResolution(attempt, Map.of(), true, reason); + } + } + + private static final class AppliedAction { + private final boolean accepted; + private final String outcome; + + private AppliedAction(boolean accepted, String outcome) { + this.accepted = accepted; + this.outcome = outcome; + } + } + + private static final class PendingOutcome { + private final String sessionId; + private final OutcomeKind kind; + private final Map request; + private final Map degradedObserveRequest; + + private PendingOutcome( + String sessionId, + OutcomeKind kind, + Map request, + Map degradedObserveRequest) { + this.sessionId = sessionId; + this.kind = kind; + this.request = request; + this.degradedObserveRequest = degradedObserveRequest; + } + + private PendingOutcome asDegradedObserve() { + return new PendingOutcome( + sessionId, + OutcomeKind.OBSERVE, + degradedObserveRequest, + degradedObserveRequest); + } + } } diff --git a/examples/mods/luanti-rin-npc/README.md b/examples/mods/luanti-rin-npc/README.md index 271068f..52dc4b6 100644 --- a/examples/mods/luanti-rin-npc/README.md +++ b/examples/mods/luanti-rin-npc/README.md @@ -14,8 +14,31 @@ copy of `sdk/lua/rin.lua`; the repository test requires both copies to match. The mod calls `core.request_http_api()` only at module scope, keeps the returned API local, uses `HTTPApiTable.fetch` asynchronously, and schedules polling with -`core.after`. It maps only `talk`, `wait`, and `refuse` to fixed game-owned -effects before committing the result. +`core.after`. It opts into `outcome-reporting-v1`, then re-reads Session +immediately before apply. The proposal must still be `pending`, with a matching +world revision (or creation revision for a non-world proposal). Stale +proposals are rejected without a game effect. It maps only `talk`, `wait`, and +`refuse` to fixed game-owned effects, and captures Luanti's monotonic game tick +at the actual accept/reject. + +The complete Create payload, request ID, and seed remain unchanged across +retries. If Rin is unavailable before any online proposal exists, the mod may +run one explicit authored fallback. State failures after a proposal fail +closed. Before submitting, the mod retains the complete Propose request and, +after `202`, its Job ID. An unresolved attempt is resumed on the next command +without advancing the turn sequence or choosing a fallback; it is removed only +in the game transaction that stores the effect, applied marker, and Outbox +entry. Either a retained attempt or an Outbox entry blocks a new turn. + +This source sample keeps applied operations and Outbox entries in memory. Each +Commit stores a safe Observe fallback containing only memory and an absolute +fact. Temporary errors retain the exact Commit; only explicit terminal errors +such as `unknown_proposal` atomically convert it to Observe. Durable ACK/delete +must succeed before eviction. Implement all marked hooks as one fallible +game/ModStorage transaction covering the effect, marker, both report payloads, +retained Create/Propose requests, optional Job ID, and sequence. The demo +removes marker/outbox state when its effect callback throws, but only a real +game transaction can undo an already-partial world mutation. Luanti's HTTP implementation follows redirects and the Lua API provides no per-request switch to disable that behavior. For that reason this example diff --git a/examples/mods/luanti-rin-npc/README.zh-CN.md b/examples/mods/luanti-rin-npc/README.zh-CN.md index bc63d86..5610972 100644 --- a/examples/mods/luanti-rin-npc/README.zh-CN.md +++ b/examples/mods/luanti-rin-npc/README.zh-CN.md @@ -13,8 +13,30 @@ 4. 在聊天中执行 `/rin_npc` 或 `/rin_npc your message`。 Mod 只在模块作用域调用 `core.request_http_api()`,把返回 API 保持为 local, -通过 `HTTPApiTable.fetch` 异步请求,并用 `core.after` 调度轮询。它只把 -`talk`、`wait` 和 `refuse` 映射到游戏拥有的固定效果,再 Commit 结果。 +通过 `HTTPApiTable.fetch` 异步请求,并用 `core.after` 调度轮询。它启用 +`outcome-reporting-v1`,应用前重新读取 Session;Proposal 必须仍是 +`pending`,而且 World Revision(非世界 Proposal 则为创建 Revision)必须 +匹配。过期 Proposal 不产生游戏效果,只报告 Rejected。Mod 只把 `talk`、 +`wait` 和 `refuse` 映射到游戏拥有的固定效果,并在实际 Accept/Reject 时 +读取 Luanti 单调游戏 Tick。 + +完整 Create Payload、Request ID 和 Seed 在所有重试间不变。只有 Rin 尚未 +生成在线 Proposal 的冷启动不可用场景,Mod 才运行一个明确编写的 Offline +Fallback;已有 Proposal 后的 State 失败必须 Fail Closed。Outbox 仍有 +Entry 时禁止开始新 Turn。Mod 会在提交前保留完整 Propose Request,并在收到 +`202` 后立即保存 Job ID;未决 Attempt 会在下一次命令中用同一身份恢复,不 +增长 Turn Sequence,也不选择 Fallback。只有游戏效果、Applied Marker 与 +Outbox 在同一事务中落盘时才移除 Attempt;未决 Attempt 与 Outbox 都会阻止 +新 Turn。 + +本源码示例只在内存保存 Applied Operation 与 Outbox。每条 Commit 同时 +保存一个只含 Memory 与绝对 Fact 的安全 Observe 降级载荷;临时错误保留 +原 Commit,只有 `unknown_proposal` 等明确终态错误才原子转换。Durable +ACK/Delete 成功后才能 Evict。用于持久世界前,应把所有标记 Hook 实现为 +可失败的权威游戏/ModStorage 事务,同时包住效果、Marker、两份报告载荷、 +保留的 Create/Propose Request、可选 Job ID 与 Sequence。示例会在效果 +Callback 抛错时移除 Marker/Outbox,但只有真实游戏事务才能回滚已部分写入 +的世界状态。 Luanti HTTP 实现会跟随重定向,而 Lua API 没有单请求关闭开关。因此示例 只接受显式 loopback HTTP Origin,并拒绝 Authorization Header;没有更 diff --git a/examples/mods/luanti-rin-npc/init.lua b/examples/mods/luanti-rin-npc/init.lua index dee601a..09d4c54 100644 --- a/examples/mods/luanti-rin-npc/init.lua +++ b/examples/mods/luanti-rin-npc/init.lua @@ -85,50 +85,69 @@ local allowed_actions = { wait = "Guide: Let us observe one more cycle before acting.", refuse = "Guide: I cannot help with an action that breaks the world rules.", } +local terminal_commit_errors = { + session_not_found = true, + unknown_proposal = true, + proposal_resolved = true, +} local sessions = {} local busy = {} -local sequence = 0 -local run_id = tostring(core.get_us_time()):gsub("[^0-9]", ""):sub(-12) +local applied_operations = {} +local outcome_outbox = {} +local proposal_attempts = {} +local run_id = tostring(core.get_us_time()):gsub("[^0-9]", "") -local function next_turn() - sequence = sequence + 1 - return sequence +local function game_tick() + -- Milliseconds from Luanti's monotonic clock are used consistently for + -- Observe and for the actual authoritative accept/reject occurrence. + return math.floor(core.get_us_time() / 1000) end local function safe_id(value) - return tostring(value):gsub("[^A-Za-z0-9._-]", "_"):sub(1, 48) + return tostring(value):gsub("[^A-Za-z0-9._-]", "_"):sub(1, 40) end local function notify(name, message) core.chat_send_player(name, "[Rin] " .. message) end +local function mark_session_missing(name, err) + if tostring(err and err.code or "") ~= "session_not_found" then return false end + local entry = sessions[name] + if entry then entry.ready = false end + return true +end + local function failed(name, err) + mark_session_missing(name, err) busy[name] = nil notify(name, "Request failed: " .. tostring(err and err.code or "integration_failed")) end -local function ensure_session(name, callback) - local existing = sessions[name] - if existing and existing.ready then - callback(existing.id) - return - end - if existing then - table.insert(existing.waiters, callback) - return +local function has_pending_outcome(name) + for _, pending in pairs(outcome_outbox) do + if pending.name == name then return true end end + return false +end - local turn = next_turn() - local entry = { - id = "luanti." .. run_id .. "." .. safe_id(name), - ready = false, - waiters = { callback }, - } - sessions[name] = entry - client:create_session({ +local function persist_new_proposal_attempt(name, attempt) + -- PRODUCTION PERSISTENCE HOOK: durably insert the complete, immutable + -- Propose request before its first POST. Store it with run_id and sequence + -- so a restart can resume the exact identity. + return proposal_attempts[name] == nil and attempt.name == name +end + +local function persist_proposal_job_id(name, attempt, job_id) + -- PRODUCTION PERSISTENCE HOOK: durably attach the Job ID immediately after + -- a 202 response and before beginning GET/DELETE reconciliation. + return proposal_attempts[name] == attempt and type(job_id) == "string" +end + +local function create_session_request(entry) + return { protocol_version = rin.PROTOCOL_VERSION, - request_id = "create." .. turn, + request_id = "create." .. entry.id, session_id = entry.id, binding = { game_id = "luanti", @@ -136,7 +155,8 @@ local function ensure_session(name, callback) content_version = "0.1.0", content_hash = "sha256:" .. string.rep("0", 64), }, - seed = turn, + seed = entry.seed, + features = { "outcome-reporting-v1" }, actors = { { id = actor_id, @@ -166,11 +186,39 @@ local function ensure_session(name, callback) enabled = true, }, }, - }, function(_, err) + } +end + +local function ensure_session(name, callback) + local entry = sessions[name] + if not entry then + entry = { + id = "luanti." .. run_id .. "." .. safe_id(name), + seed = core.get_us_time(), + ready = false, + creating = false, + waiters = {}, + sequence = 0, + } + -- Retain the complete payload. Every retry reuses the same request_id, + -- seed, binding, actor seed, and features. + entry.create_request = create_session_request(entry) + sessions[name] = entry + end + if entry.ready then + callback(entry.id) + return + end + table.insert(entry.waiters, callback) + if entry.creating then return end + entry.creating = true + + client:create_session(entry.create_request, function(_, err) local waiters = entry.waiters entry.waiters = {} + entry.creating = false if err then - sessions[name] = nil + -- Keep entry.create_request unchanged for an idempotent retry. for _, waiter in ipairs(waiters) do waiter(nil, err) end return end @@ -179,53 +227,509 @@ local function ensure_session(name, callback) end) end -local function apply_and_commit(name, session_id, turn, tick, job) +local function persist_outbox_acknowledgement(operation_id, acknowledged) + -- PRODUCTION PERSISTENCE HOOK: durably delete the acknowledged Outbox + -- entry and return false on failure. In-memory eviction happens later. + return outcome_outbox[operation_id] == acknowledged +end + +local function persist_outbox_conversion(operation_id, original, converted) + -- PRODUCTION PERSISTENCE HOOK: atomically replace only an explicitly + -- unrecoverable Commit with its pre-recorded safe Observe. Return false on + -- save failure so the exact original Commit stays retryable. + return outcome_outbox[operation_id] == original and converted.kind == "observe" +end + +local function acknowledge_outcome(operation_id, pending, callback) + -- Durable ACK/delete succeeds before in-memory eviction. + if not persist_outbox_acknowledgement(operation_id, pending) then + callback({ code = "outbox_ack_failed" }) + return + end + if outcome_outbox[operation_id] == pending then + outcome_outbox[operation_id] = nil + end + callback(nil) +end + +local function report_outcome(operation_id, callback) + local pending = outcome_outbox[operation_id] + if not pending then + callback(nil) + return + end + + if pending.kind == "observe" then + client:observe(pending.request, function(_, err) + if err then + mark_session_missing(pending.name, err) + callback(err) + return + end + acknowledge_outcome(operation_id, pending, callback) + end) + return + end + + client:commit(pending.request, function(_, err) + if not err then + acknowledge_outcome(operation_id, pending, callback) + return + end + mark_session_missing(pending.name, err) + if not terminal_commit_errors[tostring(err.code)] then + -- Temporary failures keep the exact Commit unchanged. + callback(err) + return + end + + local converted = { + name = pending.name, + session_id = pending.session_id, + kind = "observe", + request = pending.degraded_observe, + degraded_observe = pending.degraded_observe, + } + if not persist_outbox_conversion(operation_id, pending, converted) then + callback({ code = "outbox_conversion_failed" }) + return + end + outcome_outbox[operation_id] = converted + + if tostring(err.code) == "session_not_found" then + mark_session_missing(pending.name, err) + -- Next entry recreates from the exact retained payload, then + -- flushes this Observe before starting a new turn. + callback(err) + return + end + client:observe(converted.request, function(_, observe_error) + if observe_error then + mark_session_missing(pending.name, observe_error) + callback(observe_error) + return + end + acknowledge_outcome(operation_id, converted, callback) + end) + end) +end + +local function flush_outcome_outbox(name, callback) + local operation_ids = {} + for operation_id, pending in pairs(outcome_outbox) do + if pending.name == name then table.insert(operation_ids, operation_id) end + end + table.sort(operation_ids) + local index = 1 + local function report_next(err) + if err then callback(err); return end + local operation_id = operation_ids[index] + if not operation_id then callback(nil); return end + index = index + 1 + report_outcome(operation_id, report_next) + end + report_next(nil) +end + +local function safe_observe_request(session_id, operation_id, tick, applied) + -- Degraded reports contain episodic memory plus an absolute fact only. + -- They never replay relative goal/progress deltas. + return { + protocol_version = rin.PROTOCOL_VERSION, + session_id = session_id, + request_id = "fallback.observe." .. operation_id, + -- Commit and degraded Observe describe one occurrence and deliberately + -- share the same idempotency event ID. + event_id = "outcome." .. operation_id, + tick = tick, + observer_ids = { actor_id }, + source = "luanti-example", + kind = "action_outcome", + summary = applied.outcome, + tags = { "outcome", "degraded-report" }, + importance = 3, + facts = { + { + subject_id = actor_id, + predicate = "last_action_outcome", + object = applied.accepted and "accepted" or "rejected", + visibility = { actor_id }, + confidence = 100, + }, + }, + } +end + +local function commit_pending(name, session_id, operation_id, proposal_id, tick, applied) + local degraded_observe = safe_observe_request( + session_id, operation_id, tick, applied) + return { + name = name, + session_id = session_id, + kind = "commit", + request = { + protocol_version = rin.PROTOCOL_VERSION, + session_id = session_id, + request_id = "commit." .. operation_id, + proposal_id = proposal_id, + event_id = "outcome." .. operation_id, + tick = tick, + accepted = applied.accepted, + outcome = applied.outcome, + tags = { "luanti-example", "conversation" }, + }, + degraded_observe = degraded_observe, + } +end + +local function observe_pending(name, session_id, operation_id, tick, applied) + local observe = safe_observe_request(session_id, operation_id, tick, applied) + return { + name = name, + session_id = session_id, + kind = "observe", + request = observe, + degraded_observe = observe, + } +end + +local function persist_authoritative_transaction( + operation_id, applied, pending, apply_game_state, resolved_attempt, consumed_sequence) + -- PRODUCTION PERSISTENCE HOOK: replace this body with one fallible, atomic + -- game/ModStorage transaction. The actual game mutation, applied marker, + -- complete Commit plus degraded-Observe Outbox entry, retained Create + -- request, run_id, sequence, and removal of the matching unresolved + -- Proposal attempt must commit or roll back together. The demo removes + -- marker/outbox state when its effect callback throws, but only a real + -- game transaction can undo an already-partial world mutation. + if applied_operations[operation_id] then return true end + if resolved_attempt and proposal_attempts[resolved_attempt.name] ~= resolved_attempt then + return false, "proposal attempt changed before the game transaction" + end + local sequence_entry = sessions[pending.name] + local prior_sequence = sequence_entry and sequence_entry.sequence or 0 + applied_operations[operation_id] = applied + outcome_outbox[operation_id] = pending + if resolved_attempt then proposal_attempts[resolved_attempt.name] = nil end + if consumed_sequence and sequence_entry then + sequence_entry.sequence = math.max(sequence_entry.sequence, consumed_sequence) + end + local ok, err = pcall(apply_game_state) + if not ok then + if outcome_outbox[operation_id] == pending then + outcome_outbox[operation_id] = nil + end + if applied_operations[operation_id] == applied then + applied_operations[operation_id] = nil + end + if resolved_attempt and proposal_attempts[resolved_attempt.name] == nil then + proposal_attempts[resolved_attempt.name] = resolved_attempt + end + if consumed_sequence and sequence_entry then + sequence_entry.sequence = prior_sequence + end + return false, err + end + return true +end + +local function proposal_is_fresh(state, proposal) + local proposals = type(state.proposals) == "table" and state.proposals or {} + local retained = type(proposals[proposal.id]) == "table" and proposals[proposal.id] or {} + if tostring(retained.status or "") ~= "pending" then return "stale" end + local raw_world_revision = proposal.based_on_world_revision + local based_on_world_revision = tonumber(raw_world_revision) + if raw_world_revision ~= nil and not based_on_world_revision then return "stale" end + based_on_world_revision = based_on_world_revision or 0 + if based_on_world_revision > 0 then + local current = tonumber(state.world_revision) + if current and current >= 0 and current == math.floor(current) and + based_on_world_revision == math.floor(based_on_world_revision) and + current == based_on_world_revision then + return "fresh" + end + return "stale" + end + local revision = tonumber(state.revision) + local created_revision = tonumber(proposal.created_revision) + if revision and created_revision and revision >= 0 and created_revision >= 0 and + revision == math.floor(revision) and created_revision == math.floor(created_revision) and + revision == created_revision then + return "fresh" + end + return "stale" +end + +local function outcome_pending(name, err) + mark_session_missing(name, err) + busy[name] = nil + notify(name, "Handled action remains queued; no new turn may start: " .. + tostring(err and err.code or "integration_failed")) +end + +local function proposal_pending(name, err) + mark_session_missing(name, err) + busy[name] = nil + notify(name, "Proposal outcome is unresolved; the same request/job will resume: " .. + tostring(err and err.code or "proposal_outcome_unknown")) +end + +local function proposal_job_matches_attempt(attempt, job) + if type(attempt) ~= "table" or type(job) ~= "table" or + type(attempt.request) ~= "table" then + return false + end + local request = attempt.request + return type(attempt.job_id) == "string" and attempt.job_id ~= "" and + type(attempt.session_id) == "string" and + type(request.request_id) == "string" and request.request_id ~= "" and + request.session_id == attempt.session_id and + type(job.job_id) == "string" and job.job_id == attempt.job_id and + type(job.session_id) == "string" and job.session_id == attempt.session_id and + type(job.request_id) == "string" and job.request_id == request.request_id +end + +local function proposal_matches_attempt(attempt, proposal) + if type(attempt) ~= "table" or type(attempt.request) ~= "table" or + type(proposal) ~= "table" then + return false + end + local request = attempt.request + return type(proposal.id) == "string" and proposal.id ~= "" and + type(proposal.session_id) == "string" and proposal.session_id == attempt.session_id and + type(proposal.request_id) == "string" and proposal.request_id == request.request_id and + type(proposal.actor_id) == "string" and proposal.actor_id == request.actor_id and + type(proposal.tick) == "number" and type(request.tick) == "number" and + proposal.tick == request.tick and proposal.tick == math.floor(proposal.tick) +end + +local function apply_and_report_outcome(name, session_id, attempt, job, freshness) local proposal = type(job.proposal) == "table" and job.proposal or {} local action = type(proposal.action) == "table" and proposal.action or {} local action_id = tostring(action.id or "") local line = allowed_actions[action_id] - if type(proposal.proposal_id) ~= "string" then - failed(name, { code = "invalid_response" }) + if type(proposal.id) ~= "string" then + proposal_pending(name, { code = "invalid_response" }) return end - local accepted = line ~= nil - local outcome = line or "The game rejected an action outside its allowlist." + local operation_id = attempt.operation_id core.after(0, function() - if accepted then notify(name, line) end - client:commit({ - protocol_version = rin.PROTOCOL_VERSION, - session_id = session_id, - request_id = "commit." .. turn, - proposal_id = proposal.proposal_id, - event_id = "outcome." .. turn, - tick = tick, - accepted = accepted, - outcome = outcome, - tags = { "luanti-example", "conversation" }, - }, function(_, err) + local applied = applied_operations[operation_id] + if not applied then + if freshness == "unavailable" then + applied = { + accepted = false, + outcome = "The game rejected the proposal because freshness could not be verified.", + } + elseif freshness ~= "fresh" then + applied = { + accepted = false, + outcome = "The game rejected a stale proposal before applying it.", + } + elseif not line then + applied = { + accepted = false, + outcome = "The game rejected an action outside its allowlist.", + } + else + applied = { accepted = true, outcome = line } + end + + -- Capture occurrence at the actual authoritative decision. + local occurrence_tick = math.max(game_tick(), attempt.request.tick, proposal.tick) + local pending = commit_pending( + name, session_id, operation_id, proposal.id, occurrence_tick, applied) + local persisted, persistence_error = persist_authoritative_transaction( + operation_id, applied, pending, function() + if applied.accepted then notify(name, line) end + end, attempt) + if not persisted then + proposal_pending( + name, + { code = "game_transaction_failed", message = persistence_error }) + return + end + end + report_outcome(operation_id, function(err) busy[name] = nil - if err then failed(name, err) else notify(name, "Turn committed.") end + if err then outcome_pending(name, err) else notify(name, "Outcome acknowledged.") end end) end) end -local function request_turn(name, message) - if busy[name] then - notify(name, "A turn is already running.") +local function apply_offline_fallback(name, session_id, turn, resolved_attempt) + local operation_id = resolved_attempt and + (resolved_attempt.operation_id .. ".offline") or + (session_id .. ".offline." .. turn) + core.after(0, function() + local line = + "Guide (offline): Stay safe, preserve resources, and observe before acting." + local applied = { accepted = true, outcome = line } + local occurrence_tick = game_tick() + local pending = observe_pending( + name, session_id, operation_id, occurrence_tick, applied) + local persisted, persistence_error = persist_authoritative_transaction( + operation_id, + applied, + pending, + function() notify(name, line) end, + resolved_attempt, + resolved_attempt and nil or turn) + busy[name] = nil + if not persisted then + notify(name, "Offline transaction failed: " .. tostring(persistence_error)) + return + end + notify(name, "Offline outcome is queued until Rin becomes available.") + end) +end + +local submit_proposal_attempt + +local function clear_attempt_job_id(name, attempt) + if not persist_proposal_job_id(name, attempt, "") then return false end + attempt.job_id = "" + return true +end + +local function inspect_proposal_job(name, attempt, job, may_resubmit) + if not proposal_job_matches_attempt(attempt, job) then + proposal_pending(name, { code = "job_identity_mismatch" }) return end - busy[name] = true - ensure_session(name, function(session_id, session_error) - if session_error or not session_id then failed(name, session_error); return end - local turn = next_turn() - local tick = turn * 3 + local status = tostring(job.status or "") + if status == "succeeded" then + local proposal = type(job.proposal) == "table" and job.proposal or {} + if not proposal_matches_attempt(attempt, proposal) then + proposal_pending(name, { code = "proposal_identity_mismatch" }) + return + end + -- Temporary State failure is fail-closed; once an online proposal + -- exists, authored offline fallback is forbidden. + client:state({ + protocol_version = rin.PROTOCOL_VERSION, + session_id = attempt.session_id, + }, function(state, state_error) + mark_session_missing(name, state_error) + apply_and_report_outcome( + name, + attempt.session_id, + attempt, + job, + state_error and "unavailable" or proposal_is_fresh(state, proposal)) + end) + return + end + if status == "failed" or status == "stale" or status == "canceled" then + local detail = type(job.error) == "table" and job.error or {} + local code = tostring(detail.code or ("job_" .. status)) + if code == "session_not_found" then + mark_session_missing(name, { code = code }) + end + if code == "proposal_outcome_unknown" then + if may_resubmit and clear_attempt_job_id(name, attempt) then + submit_proposal_attempt(name, attempt, false) + else + proposal_pending(name, { code = code }) + end + return + end + -- A successful GET confirmed a terminal Job with no Proposal. The + -- fallback and attempt removal still happen in one game transaction. + apply_offline_fallback(name, attempt.session_id, attempt.turn, attempt) + return + end + if status ~= "queued" and status ~= "running" then + proposal_pending(name, { code = "invalid_job" }) + return + end + + client:wait_for_proposal(attempt.job_id, nil, function(resolved, wait_error) + if not wait_error then + inspect_proposal_job(name, attempt, resolved, may_resubmit) + return + end + -- A wait error may itself be a lost GET/DELETE response. Re-read the + -- Job before deciding whether a terminal state permits fallback. + client:get_proposal_job(attempt.job_id, function(confirmed, confirm_error) + if confirm_error then + if tostring(confirm_error.code) == "job_not_found" and may_resubmit and + clear_attempt_job_id(name, attempt) then + submit_proposal_attempt(name, attempt, false) + return + end + proposal_pending(name, confirm_error) + return + end + inspect_proposal_job(name, attempt, confirmed, may_resubmit) + end) + end) +end + +submit_proposal_attempt = function(name, attempt, may_resubmit) + client:submit_proposal_job(attempt.request, function(queued, queue_error) + if queue_error then + proposal_pending(name, queue_error) + return + end + local job_id = type(queued) == "table" and queued.job_id or nil + if type(job_id) ~= "string" or job_id == "" then + proposal_pending(name, { code = "invalid_submission" }) + return + end + if not persist_proposal_job_id(name, attempt, job_id) then + proposal_pending(name, { code = "proposal_attempt_persist_failed" }) + return + end + attempt.job_id = job_id + client:get_proposal_job(job_id, function(job, get_error) + if get_error then + proposal_pending(name, get_error) + return + end + inspect_proposal_job(name, attempt, job, may_resubmit) + end) + end) +end + +local function resume_proposal_attempt(name, attempt) + if tostring(attempt.job_id or "") == "" then + submit_proposal_attempt(name, attempt, true) + return + end + client:get_proposal_job(attempt.job_id, function(job, get_error) + if get_error then + if tostring(get_error.code) == "job_not_found" and + clear_attempt_job_id(name, attempt) then + submit_proposal_attempt(name, attempt, false) + return + end + proposal_pending(name, get_error) + return + end + inspect_proposal_job(name, attempt, job, true) + end) +end + +local function request_online_turn(name, message, session_id, turn) + -- Retained reports are flushed before Observe/Propose. Any temporary + -- failure fails closed and prevents a new turn. + flush_outcome_outbox(name, function(flush_error) + if flush_error then outcome_pending(name, flush_error); return end + local retained_attempt = proposal_attempts[name] + if retained_attempt then + resume_proposal_attempt(name, retained_attempt) + return + end + local operation_id = session_id .. "." .. turn + local observed_tick = game_tick() client:observe({ protocol_version = rin.PROTOCOL_VERSION, session_id = session_id, - request_id = "observe." .. turn, - event_id = "event." .. turn, - tick = tick, + request_id = "observe." .. operation_id, + event_id = "event." .. operation_id, + tick = observed_tick, observer_ids = { actor_id }, source = "luanti-example", kind = "dialogue", @@ -234,30 +738,68 @@ local function request_turn(name, message) importance = 3, }, function(_, observe_error) if observe_error then failed(name, observe_error); return end - client:submit_proposal_job({ - protocol_version = rin.PROTOCOL_VERSION, + local attempt = { + name = name, session_id = session_id, - request_id = "propose." .. turn, - actor_id = actor_id, - tick = tick + 1, - intent = "Choose one bounded response to the player.", - tags = { "conversation" }, - candidate_actions = { - { id = "talk", kind = "dialogue", description = "offer one concrete hint" }, - { id = "wait", kind = "wait", description = "ask the player to observe first" }, - { id = "refuse", kind = "refuse", description = "decline an unsafe request" }, + turn = turn, + operation_id = operation_id, + job_id = "", + request = { + protocol_version = rin.PROTOCOL_VERSION, + session_id = session_id, + request_id = "propose." .. operation_id, + actor_id = actor_id, + tick = observed_tick + 1, + intent = "Choose one bounded response to the player.", + tags = { "conversation" }, + candidate_actions = { + { id = "talk", kind = "dialogue", description = "offer one concrete hint" }, + { id = "wait", kind = "wait", description = "ask the player to observe first" }, + { id = "refuse", kind = "refuse", description = "decline an unsafe request" }, + }, }, - }, function(queued, queue_error) - if queue_error then failed(name, queue_error); return end - client:wait_for_proposal(queued.job_id, nil, function(job, job_error) - if job_error then failed(name, job_error); return end - apply_and_commit(name, session_id, turn, tick + 2, job) - end) - end) + } + if not persist_new_proposal_attempt(name, attempt) then + failed(name, { code = "proposal_attempt_persist_failed" }) + return + end + proposal_attempts[name] = attempt + local entry = sessions[name] + if entry then entry.sequence = math.max(entry.sequence, turn) end + submit_proposal_attempt(name, attempt, true) end) end) end +local function request_turn(name, message) + if busy[name] then + notify(name, "A turn is already running.") + return + end + busy[name] = true + ensure_session(name, function(session_id, session_error) + local entry = sessions[name] + local retained_attempt = proposal_attempts[name] + local turn = retained_attempt and retained_attempt.turn or + ((entry and entry.sequence or 0) + 1) + if session_error or not session_id then + if has_pending_outcome(name) then + outcome_pending(name, session_error) + elseif retained_attempt then + proposal_pending(name, session_error) + elseif entry then + -- Only cold-start unavailability (before any Rin proposal) + -- uses the explicit, bounded game-authored fallback. + apply_offline_fallback(name, entry.id, turn) + else + failed(name, session_error) + end + return + end + request_online_turn(name, message, session_id, turn) + end) +end + core.register_chatcommand("rin_npc", { params = "[message]", description = "Ask the example Rin guide for one bounded action.", diff --git a/examples/mods/luanti-rin-npc/rin.lua b/examples/mods/luanti-rin-npc/rin.lua index 5b10841..7c25520 100644 --- a/examples/mods/luanti-rin-npc/rin.lua +++ b/examples/mods/luanti-rin-npc/rin.lua @@ -14,6 +14,9 @@ local terminal_job_states = { canceled = true, } +local max_generation_content_bytes = 4 * 1024 * 1024 +local max_safe_float_integer = 9007199254740991 + local function safe_text(value, maximum, fallback) local text = tostring(value or ""):gsub("%z", " "):gsub("%s+", " ") text = text:match("^%s*(.-)%s*$") or "" @@ -30,6 +33,80 @@ local function failure(code, message, status, field) } end +local function is_protocol_identifier(value) + if type(value) ~= "string" or #value < 1 or #value > 96 then return false end + for index = 1, #value do + local byte = value:byte(index) + local letter_or_digit = (byte >= 48 and byte <= 57) or + (byte >= 65 and byte <= 90) or (byte >= 97 and byte <= 122) + if not letter_or_digit and (index == 1 or (byte ~= 45 and byte ~= 46 and byte ~= 95)) then + return false + end + end + return true +end + +local function is_nonnegative_signed_int64(value) + if type(value) ~= "number" or value ~= value or value < 0 then return false end + if type(math.type) == "function" and math.type(value) == "integer" then return true end + return value <= max_safe_float_integer and value == math.floor(value) +end + +local function resolve_job(job, result_kind, expected_job_id) + if type(job) ~= "table" then + return nil, failure("invalid_job", "Rin returned an invalid job"), true + end + if not is_protocol_identifier(job.job_id) or job.job_id ~= expected_job_id then + return nil, failure("invalid_job", "Rin returned a job with an invalid or mismatched job_id"), true + end + if result_kind == "proposal" and + (not is_protocol_identifier(job.session_id) or not is_protocol_identifier(job.request_id)) then + return nil, failure("invalid_job", "Rin returned a proposal job with invalid identity fields"), true + end + if result_kind == "generation" and not is_protocol_identifier(job.request_id) then + return nil, failure("invalid_job", "Rin returned a generation job with an invalid request_id"), true + end + if type(job.status) ~= "string" then + return nil, failure("invalid_job", "Rin returned an invalid job status"), true + end + local status = job.status + if status == "succeeded" then + if result_kind == "proposal" then + local proposal = job.proposal + if type(proposal) ~= "table" or + not is_protocol_identifier(proposal.id) or + not is_protocol_identifier(proposal.actor_id) or + proposal.session_id ~= job.session_id or + proposal.request_id ~= job.request_id or + not is_nonnegative_signed_int64(proposal.tick) then + return nil, failure( + "invalid_job", + "Successful proposal job contained invalid identity fields" + ), true + end + end + if result_kind == "generation" then + local content = type(job.result) == "table" and job.result.content or nil + if type(content) ~= "string" or content:match("^%s*$") or + content:find("%z") or #content > max_generation_content_bytes then + return nil, failure( + "invalid_job", + "Successful generation job did not include bounded content" + ), true + end + end + return job, nil, true + end + if terminal_job_states[status] then + local detail = type(job.error) == "table" and job.error or {} + return nil, failure(detail.code or ("job_" .. status), detail.message or ("Rin job ended as " .. status)), true + end + if status ~= "queued" and status ~= "running" then + return nil, failure("invalid_job", "Rin returned an unknown job status"), true + end + return nil, nil, false +end + local function validate_token(value) local token = tostring(value or "") if #token > 4096 or token:find("[%z\r\n]") or token:match("^%s") or token:match("%s$") then @@ -274,7 +351,9 @@ function Client:cancel_generation_job(job_id, callback) if not id then callback(nil, err); return end self:_request("DELETE", "/v1/generation/jobs/" .. id, nil, 200, callback) end +-- Report outcomes the game already applied or rejected; this does not execute them. function Client:commit(payload, callback) self:_post("/v1/action/commit", payload, 200, callback) end +-- Atomically report outcomes produced from one original world revision. function Client:commit_batch(payload, callback) self:_post("/v1/action/commit-batch", payload, 200, callback) end function Client:set_actor_activity(payload, callback) self:_post("/v1/session/activity", payload, 200, callback) end function Client:arbitrate(payload, callback) self:_post("/v1/world/arbitrate", payload, 200, callback) end @@ -285,7 +364,7 @@ function Client:timeline(payload, callback) self:_post("/v1/session/timeline", p function Client:replay(payload, callback) self:_post("/v1/session/replay", payload, 200, callback) end function Client:due_agents(payload, callback) self:_post("/v1/scheduler/due", payload, 200, callback) end -function Client:_wait_job(job_id, getter, canceler, options, callback) +function Client:_wait_job(job_id, getter, canceler, options, callback, result_kind) options = options or {} local deadline = tonumber(options.deadline or 25) local interval = tonumber(options.interval or 0.1) @@ -303,20 +382,22 @@ function Client:_wait_job(job_id, getter, canceler, options, callback) poll = function() getter(self, job_id, function(job, err) if err then callback(nil, err); return end - local status = tostring(job.status or "") - if status == "succeeded" then callback(job, nil); return end - if terminal_job_states[status] then - local detail = type(job.error) == "table" and job.error or {} - callback(nil, failure(detail.code or ("job_" .. status), detail.message or ("Rin job ended as " .. status))) - return - end - if status ~= "queued" and status ~= "running" then - callback(nil, failure("invalid_job", "Rin returned an unknown job status")) - return - end + local resolved, job_error, terminal = resolve_job(job, result_kind, job_id) + if terminal then callback(resolved, job_error); return end if self.now() >= expires then - canceler(self, job_id, function() end) - callback(nil, failure("job_timeout", "Rin job exceeded its deadline")) + canceler(self, job_id, function(canceled_job, cancel_error) + if cancel_error then + callback(nil, failure("job_timeout", "Rin job exceeded its deadline")) + return + end + local canceled_result, canceled_error, canceled_terminal = + resolve_job(canceled_job, result_kind, job_id) + if canceled_terminal then + callback(canceled_result, canceled_error) + else + callback(nil, failure("job_timeout", "Rin job exceeded its deadline")) + end + end) return end self.schedule(interval, poll) @@ -326,14 +407,14 @@ function Client:_wait_job(job_id, getter, canceler, options, callback) end function Client:wait_for_proposal(job_id, options, callback) - self:_wait_job(job_id, Client.get_proposal_job, Client.cancel_proposal_job, options, callback) + self:_wait_job(job_id, Client.get_proposal_job, Client.cancel_proposal_job, options, callback, "proposal") end function Client:wait_for_generation(job_id, options, callback) local configured = {} for key, value in pairs(options or {}) do configured[key] = value end if configured.deadline == nil then configured.deadline = 45 end - self:_wait_job(job_id, Client.get_generation_job, Client.cancel_generation_job, configured, callback) + self:_wait_job(job_id, Client.get_generation_job, Client.cancel_generation_job, configured, callback, "generation") end return rin diff --git a/examples/unity/RinClient.cs b/examples/unity/RinClient.cs index 14398eb..d066001 100644 --- a/examples/unity/RinClient.cs +++ b/examples/unity/RinClient.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Globalization; using System.IO; using System.Linq; using System.Security.Cryptography; @@ -12,6 +13,14 @@ public sealed class RinClient : MonoBehaviour { public const string ProtocolVersion = "rin.protocol/v1"; + private static readonly string[] AmbiguousProposalErrors = + { + "proposal_outcome_unknown", + "job_outcome_unknown", + "job_cancel_unconfirmed", + "job_timeout", + "job_id_persistence_failed", + }; [SerializeField] private string baseUrl = "http://127.0.0.1:7374"; [SerializeField] private string token = ""; @@ -46,6 +55,54 @@ public IEnumerator CreateSession(CreateSessionRequest request, Action(call.Text).data : null); } + public IEnumerator ProposalFreshness( + SessionRequest request, + string proposalId, + Action completed) + { + var call = new CallResult(); + yield return Send("POST", "/v1/session/get", JsonUtility.ToJson(request), 200, call); + if (!call.Ok) + { + completed(null); + yield break; + } + try + { + var envelope = JsonUtility.FromJson(call.Text); + if (envelope == null || envelope.data == null) + { + completed(null); + yield break; + } + string proposalJson; + ActionProposal proposal = null; + if (TryExtractObjectProperty( + call.Text, + "proposals", + proposalId, + out proposalJson)) + { + proposal = JsonUtility.FromJson(proposalJson); + if (proposal != null) + { + proposal.has_unsupported_action_parameters = + ActionHasUnsupportedParameters(proposalJson); + } + } + completed(new ProposalFreshnessResult + { + revision = envelope.data.revision, + world_revision = envelope.data.world_revision, + proposal = proposal, + }); + } + catch (ArgumentException) + { + completed(null); + } + } + public IEnumerator Commit(CommitRequest request, Action completed) { var call = new CallResult(); @@ -53,6 +110,18 @@ public IEnumerator Commit(CommitRequest request, Action complete completed(call.Ok ? JsonUtility.FromJson(call.Text).data : null); } + public IEnumerator CommitReport(CommitRequest request, Action completed) + { + var call = new CallResult(); + yield return Send("POST", "/v1/action/commit", JsonUtility.ToJson(request), 200, call); + completed(new ReportAttempt + { + ok = call.Ok, + error_code = call.Ok ? "" : call.ErrorCode, + data = call.Ok ? JsonUtility.FromJson(call.Text).data : null, + }); + } + public IEnumerator CommitBatch(BatchCommitRequest request, Action completed) { var call = new CallResult(); @@ -102,50 +171,67 @@ public IEnumerator ProposeWithFallback( ProposeRequest request, string fallbackActionId, Action completed, - Func isCanceled = null) + Func isCanceled = null, + bool allowOfflineBeforeSubmit = true, + string knownJobId = "", + Func persistJobId = null) { - if (!IsConfigured) + var jobId = knownJobId; + if (jobId == null || (jobId.Length > 0 && !IsValidProtocolId(jobId))) { - completed(BuildOfflineResult(request, fallbackActionId, "invalid_endpoint", "")); + completed(BuildClosedResult("invalid_job", "")); yield break; } - - var submissionCall = new CallResult(); - yield return Send( - "POST", - "/v1/jobs/propose", - JsonUtility.ToJson(request), - 202, - submissionCall); - if (!submissionCall.Ok) + if (!IsConfigured) { - completed(BuildOfflineResult(request, fallbackActionId, submissionCall.ErrorCode, "")); + completed( + allowOfflineBeforeSubmit && string.IsNullOrEmpty(jobId) + ? BuildOfflineResult( + request, + fallbackActionId, + "invalid_endpoint", + "") + : BuildClosedResult("proposal_outcome_unknown", jobId)); yield break; } - var submission = JsonUtility.FromJson(submissionCall.Text); - var jobId = submission != null && submission.data != null ? submission.data.job_id : ""; + var recoveryPostUsed = false; if (string.IsNullOrEmpty(jobId)) { - completed(BuildOfflineResult(request, fallbackActionId, "invalid_submission", "")); - yield break; + var submitted = new SubmissionAttempt(); + yield return SubmitProposal(request, persistJobId, submitted); + if (!submitted.ok) + { + // Send may have reached Rin even when its response was lost. + // The persisted stable request remains resumable, so never run + // a second, offline action after online submission begins. + completed(BuildClosedResult( + "proposal_outcome_unknown", + submitted.job_id)); + yield break; + } + jobId = submitted.job_id; } var deadline = Time.realtimeSinceStartup + jobDeadlineSeconds; while (Time.realtimeSinceStartup < deadline) { + if (!IsValidProtocolId(jobId)) + { + completed(BuildClosedResult("invalid_job", "")); + yield break; + } if (isCanceled != null && isCanceled()) { var cancelCall = new CallResult(); yield return Send("DELETE", "/v1/jobs/" + UnityWebRequest.EscapeURL(jobId), null, 200, cancelCall); - completed(new AdapterResult - { - source = "canceled", - committable = false, - fallback_reason = "job_canceled", - job_id = jobId, - proposal = null, - }); + completed(ResolveCancellation( + cancelCall, + request, + fallbackActionId, + jobId, + false, + "job_cancel_unconfirmed")); yield break; } @@ -153,18 +239,64 @@ public IEnumerator ProposeWithFallback( yield return Send("GET", "/v1/jobs/" + UnityWebRequest.EscapeURL(jobId), null, 200, pollCall); if (!pollCall.Ok) { - completed(BuildOfflineResult(request, fallbackActionId, pollCall.ErrorCode, jobId)); + if (pollCall.ErrorCode == "job_not_found" && !recoveryPostUsed) + { + var recovered = new SubmissionAttempt(); + recoveryPostUsed = true; + yield return SubmitProposal(request, persistJobId, recovered); + if (!recovered.ok) + { + completed(BuildClosedResult( + "proposal_outcome_unknown", + string.IsNullOrEmpty(recovered.job_id) + ? jobId + : recovered.job_id)); + yield break; + } + jobId = recovered.job_id; + continue; + } + completed(BuildClosedResult("job_outcome_unknown", jobId)); + yield break; + } + JobEnvelope envelope; + try + { + envelope = JsonUtility.FromJson(pollCall.Text); + } + catch (ArgumentException) + { + completed(BuildClosedResult("invalid_job", jobId)); yield break; } - var envelope = JsonUtility.FromJson(pollCall.Text); var job = envelope != null ? envelope.data : null; if (job == null) { - completed(BuildOfflineResult(request, fallbackActionId, "invalid_job", jobId)); + completed(BuildClosedResult("invalid_job", jobId)); yield break; } - if (job.status == "succeeded" && job.proposal != null) + if (!JobMatchesRequest(job, jobId, request)) { + completed(BuildClosedResult("invalid_job_identity", jobId)); + yield break; + } + if (!JobShapeMatchesStatus(job, pollCall.Text)) + { + completed(BuildClosedResult("invalid_job", jobId)); + yield break; + } + if (job.status == "succeeded") + { + if (job.proposal == null) + { + completed(BuildClosedResult("invalid_job", jobId)); + yield break; + } + if (!ProposalMatchesRequest(job.proposal, request, pollCall.Text)) + { + completed(BuildClosedResult("invalid_job_identity", jobId)); + yield break; + } completed(new AdapterResult { source = "sidecar", @@ -177,15 +309,40 @@ public IEnumerator ProposeWithFallback( } if (job.status == "failed" || job.status == "stale" || job.status == "canceled") { - var reason = job.error != null && !string.IsNullOrEmpty(job.error.code) - ? job.error.code - : "job_" + job.status; - completed(BuildOfflineResult(request, fallbackActionId, reason, jobId)); + string reason; + if (!TryGetTerminalErrorCode(job, pollCall.Text, out reason)) + { + completed(BuildClosedResult("job_outcome_unknown", jobId)); + yield break; + } + if (reason == "proposal_outcome_unknown" && !recoveryPostUsed) + { + var recovered = new SubmissionAttempt(); + recoveryPostUsed = true; + yield return SubmitProposal(request, persistJobId, recovered); + if (!recovered.ok) + { + completed(BuildClosedResult( + "proposal_outcome_unknown", + string.IsNullOrEmpty(recovered.job_id) + ? jobId + : recovered.job_id)); + yield break; + } + jobId = recovered.job_id; + continue; + } + completed(BuildTerminalResult( + request, + fallbackActionId, + jobId, + reason, + true)); yield break; } if (job.status != "queued" && job.status != "running") { - completed(BuildOfflineResult(request, fallbackActionId, "invalid_job", jobId)); + completed(BuildClosedResult("invalid_job", jobId)); yield break; } yield return new WaitForSecondsRealtime(pollIntervalSeconds); @@ -193,7 +350,77 @@ public IEnumerator ProposeWithFallback( var timeoutCancel = new CallResult(); yield return Send("DELETE", "/v1/jobs/" + UnityWebRequest.EscapeURL(jobId), null, 200, timeoutCancel); - completed(BuildOfflineResult(request, fallbackActionId, "job_timeout", jobId)); + completed(ResolveCancellation( + timeoutCancel, + request, + fallbackActionId, + jobId, + true, + "job_outcome_unknown")); + } + + private IEnumerator SubmitProposal( + ProposeRequest request, + Func persistJobId, + SubmissionAttempt result) + { + result.ok = false; + var call = new CallResult(); + yield return Send( + "POST", + "/v1/jobs/propose", + JsonUtility.ToJson(request), + 202, + call); + if (!call.Ok) + { + result.error_code = call.ErrorCode; + yield break; + } + + SubmissionEnvelope submission; + try + { + submission = JsonUtility.FromJson(call.Text); + } + catch (ArgumentException) + { + result.error_code = "invalid_job"; + yield break; + } + string submissionJson; + string wireJobId; + var jobId = submission != null && submission.data != null + ? submission.data.job_id + : null; + if (!TryExtractTopLevelObjectProperty(call.Text, "data", out submissionJson) || + !TryReadTopLevelProtocolIdProperty( + submissionJson, + "job_id", + out wireJobId) || + !string.Equals(jobId, wireJobId, StringComparison.Ordinal)) + { + result.error_code = "invalid_job"; + yield break; + } + result.job_id = jobId; + try + { + // Persist the accepted 202 identity before polling or returning + // control. A failed callback leaves the stable request resumable. + if (persistJobId != null && !persistJobId(jobId)) + { + result.error_code = "job_id_persistence_failed"; + yield break; + } + } + catch (Exception) + { + result.error_code = "job_id_persistence_failed"; + yield break; + } + result.ok = true; + result.error_code = ""; } private IEnumerator Send( @@ -277,8 +504,27 @@ private static AdapterResult BuildOfflineResult( job_id = jobId, }; } - var selected = request.candidate_actions.FirstOrDefault( - action => action != null && action.id == fallbackActionId) ?? request.candidate_actions[0]; + ActionSpec selected; + if (string.IsNullOrEmpty(fallbackActionId)) + { + selected = request.candidate_actions[0]; + } + else + { + selected = request.candidate_actions.FirstOrDefault( + action => action != null && action.id == fallbackActionId); + if (selected == null) + { + return new AdapterResult + { + source = "error", + committable = false, + fallback_reason = "invalid_fallback", + job_id = jobId ?? "", + proposal = null, + }; + } + } var stance = new[] { "engage", "partial", "redirect", "refuse", "wait" }.Contains(selected.kind) ? selected.kind : "engage"; @@ -312,6 +558,293 @@ private static AdapterResult BuildOfflineResult( }; } + private static AdapterResult ResolveCancellation( + CallResult call, + ProposeRequest request, + string fallbackActionId, + string jobId, + bool allowConfirmedTerminalFallback, + string unconfirmedReason) + { + if (!IsValidProtocolId(jobId)) + return BuildClosedResult("invalid_job", ""); + if (call == null || !call.Ok) + return BuildClosedResult(unconfirmedReason, jobId); + + ProposalJob job; + try + { + var envelope = JsonUtility.FromJson(call.Text); + job = envelope != null ? envelope.data : null; + } + catch (ArgumentException) + { + return BuildClosedResult("invalid_job", jobId); + } + if (job == null) + return BuildClosedResult("invalid_job", jobId); + if (!JobMatchesRequest(job, jobId, request)) + return BuildClosedResult("invalid_job_identity", jobId); + if (!JobShapeMatchesStatus(job, call.Text)) + return BuildClosedResult("invalid_job", jobId); + if (job.status == "succeeded") + { + if (job.proposal == null) + return BuildClosedResult("invalid_job", jobId); + if (!ProposalMatchesRequest(job.proposal, request, call.Text)) + return BuildClosedResult("invalid_job_identity", jobId); + return new AdapterResult + { + source = "sidecar", + committable = true, + fallback_reason = "", + job_id = jobId, + proposal = job.proposal, + }; + } + if (job.status == "failed" || job.status == "stale" || job.status == "canceled") + { + string reason; + if (!TryGetTerminalErrorCode(job, call.Text, out reason)) + return BuildClosedResult("job_outcome_unknown", jobId); + return BuildTerminalResult( + request, + fallbackActionId, + jobId, + reason, + allowConfirmedTerminalFallback); + } + if (job.status == "queued" || job.status == "running") + return BuildClosedResult(unconfirmedReason, jobId); + return BuildClosedResult("invalid_job", jobId); + } + + private static bool JobMatchesRequest( + ProposalJob job, + string jobId, + ProposeRequest request) + { + return job != null && + request != null && + string.Equals( + job.protocol_version, + ProtocolVersion, + StringComparison.Ordinal) && + IsValidProtocolId(job.job_id) && + IsValidProtocolId(job.session_id) && + IsValidProtocolId(job.request_id) && + IsValidProtocolId(jobId) && + IsValidProtocolId(request.session_id) && + IsValidProtocolId(request.request_id) && + string.Equals(job.job_id, jobId, StringComparison.Ordinal) && + string.Equals(job.session_id, request.session_id, StringComparison.Ordinal) && + string.Equals(job.request_id, request.request_id, StringComparison.Ordinal); + } + + private static bool JobShapeMatchesStatus( + ProposalJob job, + string responseJson) + { + string jobJson; + string wireStatus; + if (job == null || + !TryExtractTopLevelObjectProperty(responseJson, "data", out jobJson) || + !TryReadTopLevelProtocolIdProperty(jobJson, "status", out wireStatus) || + !string.Equals(job.status, wireStatus, StringComparison.Ordinal)) + return false; + + var proposalStart = FindTopLevelPropertyValue(jobJson, "proposal"); + var errorStart = FindTopLevelPropertyValue(jobJson, "error"); + if (job.status == "succeeded") + { + string proposalJson; + return proposalStart >= 0 && + errorStart < 0 && + job.error == null && + job.proposal != null && + TryExtractTopLevelObjectProperty( + jobJson, + "proposal", + out proposalJson); + } + if (job.status == "failed" || + job.status == "stale" || + job.status == "canceled") + { + string reason; + return proposalStart < 0 && + errorStart >= 0 && + job.proposal == null && + TryGetTerminalErrorCode(job, responseJson, out reason); + } + if (job.status == "queued" || job.status == "running") + { + return proposalStart < 0 && + errorStart < 0 && + job.proposal == null && + job.error == null; + } + return false; + } + + private static bool ProposalMatchesRequest( + ActionProposal proposal, + ProposeRequest request, + string responseJson) + { + string dataJson; + string proposalJson; + long wireTick; + if (proposal == null || + request == null || + !TryExtractTopLevelObjectProperty(responseJson, "data", out dataJson) || + !TryExtractTopLevelObjectProperty(dataJson, "proposal", out proposalJson) || + !TryReadTopLevelInt64Property(proposalJson, "tick", out wireTick)) + return false; + proposal.has_unsupported_action_parameters = + ActionHasUnsupportedParameters(proposalJson); + return !proposal.has_unsupported_action_parameters && + IsValidProtocolId(proposal.id) && + IsValidProtocolId(proposal.session_id) && + IsValidProtocolId(proposal.request_id) && + IsValidProtocolId(proposal.actor_id) && + IsValidProtocolId(request.session_id) && + IsValidProtocolId(request.request_id) && + IsValidProtocolId(request.actor_id) && + string.Equals(proposal.session_id, request.session_id, StringComparison.Ordinal) && + string.Equals(proposal.request_id, request.request_id, StringComparison.Ordinal) && + string.Equals(proposal.actor_id, request.actor_id, StringComparison.Ordinal) && + wireTick >= 0 && + request.tick >= 0 && + proposal.tick == wireTick && + wireTick == request.tick && + ActionMatchesCandidate(proposal.action, request.candidate_actions); + } + + private static bool ActionHasUnsupportedParameters(string proposalJson) + { + string actionJson; + // This example advertises no parameterized actions. Presence of the + // protocol's arbitrary parameters map is therefore not representable + // by JsonUtility and must fail closed instead of being silently dropped. + return !TryExtractTopLevelObjectProperty(proposalJson, "action", out actionJson) || + FindTopLevelPropertyValue(actionJson, "parameters") >= 0; + } + + private static bool ActionMatchesCandidate( + ActionSpec action, + ActionSpec[] candidates) + { + if (action == null || + candidates == null || + !IsValidProtocolId(action.id) || + !IsValidProtocolId(action.kind) || + string.IsNullOrWhiteSpace(action.description) || + action.description.Length > 300 || + !ValidTargetIds(action.target_ids)) + return false; + return candidates.Any(candidate => ActionSpecsEqual(action, candidate)); + } + + private static bool ActionSpecsEqual(ActionSpec left, ActionSpec right) + { + if (left == null || right == null || + !IsValidProtocolId(right.id) || + !IsValidProtocolId(right.kind) || + string.IsNullOrWhiteSpace(right.description) || + right.description.Length > 300 || + !ValidTargetIds(right.target_ids) || + !string.Equals(left.id, right.id, StringComparison.Ordinal) || + !string.Equals(left.kind, right.kind, StringComparison.Ordinal) || + !string.Equals(left.description, right.description, StringComparison.Ordinal)) + return false; + if (left.target_ids == null || right.target_ids == null) + return left.target_ids == null && right.target_ids == null; + return left.target_ids.SequenceEqual(right.target_ids); + } + + private static bool ValidTargetIds(string[] values) + { + return values == null || + (values.Length <= 32 && values.All(IsValidProtocolId)); + } + + private static bool TryGetTerminalErrorCode( + ProposalJob job, + string responseJson, + out string code) + { + code = null; + string jobJson; + string errorJson; + string wireCode; + if (job == null || + job.error == null || + !TryExtractTopLevelObjectProperty(responseJson, "data", out jobJson) || + !TryExtractTopLevelObjectProperty(jobJson, "error", out errorJson) || + !TryReadTopLevelProtocolIdProperty(errorJson, "code", out wireCode) || + !string.Equals(job.error.code, wireCode, StringComparison.Ordinal)) + return false; + code = wireCode; + return true; + } + + private static bool IsValidProtocolId(string value) + { + if (string.IsNullOrEmpty(value) || value.Length > 96) return false; + for (var index = 0; index < value.Length; index++) + { + var character = value[index]; + var alphaNumeric = + (character >= 'A' && character <= 'Z') || + (character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9'); + if (index == 0) + { + if (!alphaNumeric) return false; + } + else if (!alphaNumeric && character != '.' && character != '_' && character != '-') + { + return false; + } + } + return true; + } + + public static bool IsProtocolId(string value) + { + return IsValidProtocolId(value); + } + + private static AdapterResult BuildTerminalResult( + ProposeRequest request, + string fallbackActionId, + string jobId, + string reason, + bool allowFallback) + { + if (AmbiguousProposalErrors.Contains(reason)) + return BuildClosedResult(reason, jobId); + return allowFallback + ? BuildOfflineResult(request, fallbackActionId, reason, jobId) + : BuildClosedResult(reason, jobId, "canceled"); + } + + private static AdapterResult BuildClosedResult( + string reason, + string jobId, + string source = "error") + { + return new AdapterResult + { + source = source, + committable = false, + fallback_reason = SafeCode(reason), + job_id = jobId ?? "", + proposal = null, + }; + } + private static bool ValidateEndpoint(string value, string bearerToken) { Uri uri; @@ -356,6 +889,242 @@ private static string SafeCode(string value) return safe; } + // Unity's JsonUtility skips string-keyed maps. Extract only the requested + // proposal object from SessionState.proposals, then deserialize that object + // normally; no third-party JSON dependency is required for this example. + private static bool TryExtractObjectProperty( + string json, + string containerName, + string propertyName, + out string objectJson) + { + objectJson = null; + var containerStart = FindPropertyValue(json, containerName, 0, json.Length); + if (containerStart < 0 || json[containerStart] != '{') return false; + var containerEnd = FindMatchingContainer(json, containerStart); + if (containerEnd < 0) return false; + var valueStart = FindPropertyValue( + json, + propertyName, + containerStart + 1, + containerEnd); + if (valueStart < 0 || json[valueStart] != '{') return false; + var valueEnd = FindMatchingContainer(json, valueStart); + if (valueEnd < 0 || valueEnd > containerEnd) return false; + objectJson = json.Substring(valueStart, valueEnd - valueStart + 1); + return true; + } + + private static int FindPropertyValue( + string json, + string propertyName, + int start, + int end) + { + var needle = "\"" + EscapeJsonString(propertyName) + "\""; + var search = start; + while (search < end) + { + var property = json.IndexOf(needle, search, StringComparison.Ordinal); + if (property < 0 || property >= end) return -1; + var cursor = property + needle.Length; + while (cursor < end && char.IsWhiteSpace(json[cursor])) cursor++; + if (cursor < end && json[cursor] == ':') + { + cursor++; + while (cursor < end && char.IsWhiteSpace(json[cursor])) cursor++; + return cursor < end ? cursor : -1; + } + search = property + needle.Length; + } + return -1; + } + + private static bool TryExtractTopLevelObjectProperty( + string json, + string propertyName, + out string objectJson) + { + objectJson = null; + var valueStart = FindTopLevelPropertyValue(json, propertyName); + if (valueStart < 0 || json[valueStart] != '{') return false; + var valueEnd = FindMatchingContainer(json, valueStart); + if (valueEnd < 0) return false; + objectJson = json.Substring(valueStart, valueEnd - valueStart + 1); + return true; + } + + private static bool TryReadTopLevelInt64Property( + string json, + string propertyName, + out long value) + { + value = 0; + var valueStart = FindTopLevelPropertyValue(json, propertyName); + if (valueStart < 0) return false; + + var cursor = valueStart; + if (cursor < json.Length && json[cursor] == '-') cursor++; + var digitsStart = cursor; + while (cursor < json.Length && char.IsDigit(json[cursor])) cursor++; + if (cursor == digitsStart) return false; + if (json[digitsStart] == '0' && cursor - digitsStart > 1) return false; + + var token = json.Substring(valueStart, cursor - valueStart); + while (cursor < json.Length && char.IsWhiteSpace(json[cursor])) cursor++; + if (cursor >= json.Length || (json[cursor] != ',' && json[cursor] != '}')) + return false; + return long.TryParse( + token, + NumberStyles.AllowLeadingSign, + CultureInfo.InvariantCulture, + out value); + } + + private static bool TryReadTopLevelProtocolIdProperty( + string json, + string propertyName, + out string value) + { + value = null; + var valueStart = FindTopLevelPropertyValue(json, propertyName); + if (valueStart < 0 || json[valueStart] != '"') return false; + + var cursor = valueStart + 1; + var contentStart = cursor; + while (cursor < json.Length && json[cursor] != '"') + { + // Protocol identifiers are ASCII and never require JSON escapes. + if (json[cursor] == '\\') return false; + cursor++; + } + if (cursor >= json.Length) return false; + var decoded = json.Substring(contentStart, cursor - contentStart); + cursor++; + while (cursor < json.Length && char.IsWhiteSpace(json[cursor])) cursor++; + if (cursor >= json.Length || (json[cursor] != ',' && json[cursor] != '}')) + return false; + if (!IsValidProtocolId(decoded)) return false; + value = decoded; + return true; + } + + private static int FindTopLevelPropertyValue( + string json, + string propertyName) + { + if (string.IsNullOrEmpty(json)) return -1; + var rootStart = 0; + while (rootStart < json.Length && char.IsWhiteSpace(json[rootStart])) rootStart++; + if (rootStart >= json.Length || json[rootStart] != '{') return -1; + + var needle = "\"" + EscapeJsonString(propertyName) + "\""; + var depth = 0; + for (var index = rootStart; index < json.Length; index++) + { + var character = json[index]; + if (character == '{' || character == '[') + { + depth++; + continue; + } + if (character == '}' || character == ']') + { + depth--; + if (depth <= 0) return -1; + continue; + } + if (character != '"') continue; + + var stringStart = index; + var escaped = false; + for (index++; index < json.Length; index++) + { + character = json[index]; + if (escaped) + { + escaped = false; + } + else if (character == '\\') + { + escaped = true; + } + else if (character == '"') + { + break; + } + } + if (index >= json.Length) return -1; + if (depth != 1 || + index - stringStart + 1 != needle.Length || + string.CompareOrdinal( + json, + stringStart, + needle, + 0, + needle.Length) != 0) + { + continue; + } + + var cursor = index + 1; + while (cursor < json.Length && char.IsWhiteSpace(json[cursor])) cursor++; + if (cursor >= json.Length || json[cursor] != ':') continue; + cursor++; + while (cursor < json.Length && char.IsWhiteSpace(json[cursor])) cursor++; + return cursor < json.Length ? cursor : -1; + } + return -1; + } + + private static int FindMatchingContainer(string json, int start) + { + var opener = json[start]; + var closer = opener == '{' ? '}' : opener == '[' ? ']' : '\0'; + if (closer == '\0') return -1; + var depth = 0; + var inString = false; + var escaped = false; + for (var index = start; index < json.Length; index++) + { + var character = json[index]; + if (inString) + { + if (escaped) + { + escaped = false; + } + else if (character == '\\') + { + escaped = true; + } + else if (character == '"') + { + inString = false; + } + continue; + } + if (character == '"') + { + inString = true; + } + else if (character == opener) + { + depth++; + } + else if (character == closer && --depth == 0) + { + return index; + } + } + return -1; + } + + private static string EscapeJsonString(string value) + { + return (value ?? "").Replace("\\", "\\\\").Replace("\"", "\\\""); + } + private sealed class CallResult { public bool Ok; @@ -363,6 +1132,13 @@ private sealed class CallResult public string Text; } + private sealed class SubmissionAttempt + { + public bool ok; + public string error_code; + public string job_id; + } + private sealed class CappedDownloadHandler : DownloadHandlerScript { private readonly int maximum; @@ -400,6 +1176,7 @@ public string GetText() [Serializable] private sealed class ArbitrationEnvelope { public bool ok; public ArbitrationResult data; public ErrorDetail error; } [Serializable] private sealed class TimelineEnvelope { public bool ok; public TimelineResponse data; public ErrorDetail error; } [Serializable] private sealed class ReplayEnvelope { public bool ok; public ReplaySnapshot data; public ErrorDetail error; } + [Serializable] private sealed class StateEnvelope { public bool ok; public SessionStateHead data; public ErrorDetail error; } } [Serializable] public sealed class ActionSpec @@ -436,6 +1213,11 @@ [Serializable] public sealed class Goal public int progress; public int target_progress; public string status; + public long updated_tick; + public long status_updated_tick; + public string status_source_event_id; + public long progress_accumulator; + public bool status_explicit; } [Serializable] public sealed class ActorSeed @@ -461,6 +1243,25 @@ [Serializable] public sealed class CreateSessionRequest public ActorSeed[] actors; } +[Serializable] public sealed class SessionRequest +{ + public string protocol_version = RinClient.ProtocolVersion; + public string session_id; +} + +[Serializable] public sealed class SessionStateHead +{ + public long revision; + public long world_revision; +} + +[Serializable] public sealed class ProposalFreshnessResult +{ + public long revision; + public long world_revision; + public ActionProposal proposal; +} + [Serializable] public sealed class ProposeRequest { public string protocol_version = RinClient.ProtocolVersion; @@ -527,6 +1328,9 @@ [Serializable] public sealed class ActionProposal public string goal_id; public Goal proposed_goal; public string status; + public string outcome_event_id; + public long outcome_tick; + [NonSerialized] public bool has_unsupported_action_parameters; } [Serializable] public sealed class Fact @@ -537,6 +1341,7 @@ [Serializable] public sealed class Fact public string[] visibility; public int confidence; public string source_event_id; + public long observed_tick; } [Serializable] public sealed class GoalUpdate @@ -730,6 +1535,13 @@ [Serializable] public sealed class MutationResult public bool duplicate; } +[Serializable] public sealed class ReportAttempt +{ + public bool ok; + public string error_code; + public MutationResult data; +} + [Serializable] public sealed class AdapterResult { public string source; diff --git a/examples/unity/RinNpcExample.cs b/examples/unity/RinNpcExample.cs index 86030bc..25908aa 100644 --- a/examples/unity/RinNpcExample.cs +++ b/examples/unity/RinNpcExample.cs @@ -1,22 +1,612 @@ +using System; using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; using UnityEngine; public sealed class RinNpcExample : MonoBehaviour { + private const long NpcThinkEveryTicks = 5; + [SerializeField] private RinClient rin; + private readonly Dictionary appliedOperations = + new Dictionary(); + private readonly Dictionary reportOutbox = + new Dictionary(); + private readonly Dictionary proposalAttempts = + new Dictionary(); + private string runId; + private long operationSequence; + private long lastAuthoritativeTick; + private CreateSessionRequest createRequest; + private bool authoritativeStateReady; + private bool turnRunning; + + private void Awake() + { + // Recovery is a startup gate. A load error is not an empty save, and + // no new identity or turn may exist until initialization is durable. + authoritativeStateReady = RestoreAuthoritativeState(); + if (!authoritativeStateReady) + { + Debug.LogError( + "Authoritative Rin state could not be restored; NPC turns are disabled."); + } + } + public void AskNpcToRespond() { - StartCoroutine(ProposeAndApply()); + if (!authoritativeStateReady) + { + Debug.LogError( + "Rin NPC turn refused until authoritative state recovery succeeds."); + return; + } + if (turnRunning) + { + Debug.LogWarning("A Rin NPC turn is already running."); + return; + } + turnRunning = true; + StartCoroutine(RunNpcTurn()); + } + + private IEnumerator RunNpcTurn() + { + try + { + yield return ProposeAndApply(); + } + finally + { + turnRunning = false; + } } private IEnumerator ProposeAndApply() { + if (!authoritativeStateReady) yield break; + var sessionId = "playthrough." + runId; + var resuming = proposalAttempts.TryGetValue(sessionId, out var attempt); + // Keep this complete request stable. A lost response is retried on the + // next turn with the same request ID and game-owned fields. MutationResult created = null; - yield return rin.CreateSession(new CreateSessionRequest + yield return rin.CreateSession(createRequest, value => created = value); + if (created == null) + { + Debug.LogWarning(resuming + ? "Rin create unavailable; the persisted Proposal attempt will fail closed." + : "Rin create unavailable; an empty Outbox may use the authored fallback."); + } + + // Every authoritative entry retries pending Commit or fallback Observe + // reports before proposing or applying another action. + var pendingReported = false; + yield return FlushReportOutbox(value => pendingReported = value); + if (!pendingReported) yield break; + + if (!resuming) + { + if (operationSequence == long.MaxValue) + { + Debug.LogError( + "Operation sequence exhausted; no new Proposal can be identified safely."); + yield break; + } + if (!TryAllocateFreshProposalTick(out var newGameTick)) + { + Debug.LogError("Authoritative tick exhausted; no new Proposal was submitted."); + yield break; + } + var nextSequence = operationSequence + 1; + var newOperationId = + runId + "." + nextSequence.ToString(CultureInfo.InvariantCulture); + var stableRequest = BuildProposeRequest( + sessionId, + newOperationId, + newGameTick); + attempt = new ProposalAttempt( + newOperationId, + nextSequence, + stableRequest, + "wait", + ""); + // Persist the complete stable request, operation ID, and consumed + // sequence before the first POST can create a Proposal Job. + if (!PersistNewProposalAttempt( + sessionId, + attempt, + nextSequence, + newGameTick)) + { + Debug.LogError( + "Could not durably save the Proposal attempt; nothing was submitted."); + yield break; + } + proposalAttempts.Add(sessionId, attempt); + operationSequence = nextSequence; + lastAuthoritativeTick = newGameTick; + } + else + { + operationSequence = Math.Max(operationSequence, attempt.sequence); + } + + var operationId = attempt.operationId; + var request = attempt.request; + AdapterResult result = null; + yield return rin.ProposeWithFallback( + request, + attempt.fallbackActionId, + value => result = value, + allowOfflineBeforeSubmit: !resuming && created == null, + knownJobId: attempt.jobId, + persistJobId: jobId => RecordProposalJobId( + sessionId, + operationId, + jobId)); + if (result == null || result.proposal == null) yield break; + if (result.proposal.tick < 0) + { + Debug.LogError("Proposal tick is not a non-negative protocol integer."); + yield break; + } + + var planned = PlanActionInGame(result.proposal.action); + PendingReport report; + if (result.committable) { - request_id = "create.playthrough-1", - session_id = "playthrough-1", + ProposalFreshnessResult freshness = null; + yield return rin.ProposalFreshness( + new SessionRequest { session_id = sessionId }, + result.proposal.id, + value => freshness = value); + if (freshness == null) + { + // We already have an online proposal. Reject it authoritatively; + // never reinterpret a read failure as permission for fallback. + planned = new AppliedAction( + result.proposal.action != null ? result.proposal.action.id : "", + false, + "The game rejected the proposal because freshness could not be verified."); + } + else if (!ProposalIsFresh(freshness, result.proposal, request)) + { + planned = new AppliedAction( + result.proposal.action != null ? result.proposal.action.id : "", + false, + "The game rejected a stale proposal before applying any effect."); + } + report = BuildCommitReport( + request.session_id, + operationId, + result.proposal.id, + 0, + planned); + } + else + { + // Authored local fallbacks have no Rin Proposal to Commit. Reconcile + // the game effect as a stable Observe with these exact IDs and tick. + report = PendingReport.Observe(BuildFallbackObserveRequest( + request.session_id, + operationId, + 0, + planned)); + } + if (ApplyAndEnqueueAuthoritativeOperation( + sessionId, + operationId, + planned, + report, + result.proposal.tick) == null) + yield break; + yield return FlushReportOutbox(_ => { }); + } + + private bool RestoreAuthoritativeState() + { + var loaded = LoadAuthoritativeState(); + if (loaded == null) + { + Debug.LogError("Authoritative state loader returned no result."); + return false; + } + if (loaded.status == AuthoritativeStateLoadStatus.Loaded) + { + if (loaded.state == null || !TryHydrateAuthoritativeState(loaded.state)) + { + Debug.LogError( + "Persisted authoritative state is missing, corrupt, or inconsistent."); + return false; + } + return true; + } + if (loaded.status != AuthoritativeStateLoadStatus.NotFound) + { + Debug.LogError( + "Authoritative state load failed: " + (loaded.error ?? "unknown")); + return false; + } + + // Only a positive NotFound result may mint a new identity. Save the + // complete initialized object before exposing it to the running scene. + var newRunId = Guid.NewGuid().ToString("N"); + var initialized = new AuthoritativeState + { + schemaVersion = 2, + runId = newRunId, + operationSequence = 0, + lastAuthoritativeTick = 0, + createRequest = BuildCreateRequest(newRunId), + proposalAttempts = new ProposalAttemptState[0], + appliedOperations = new AppliedOperationState[0], + reportOutbox = new PendingReportState[0], + }; + if (!PersistAuthoritativeStateInitialization(initialized)) + { + Debug.LogError("Could not durably initialize authoritative state."); + return false; + } + return TryHydrateAuthoritativeState(initialized); + } + + private AuthoritativeStateLoadResult LoadAuthoritativeState() + { + // PRODUCTION RESTORE HOOK: synchronously deserialize one + // AuthoritativeState and return Loaded(state), NotFound() only when + // storage positively confirms absence, or Failed(error). Never map an + // I/O, parse, or schema-version error to NotFound. This example remains + // disabled until the game wires its save provider. + return AuthoritativeStateLoadResult.Failed("restore hook not configured"); + } + + private bool PersistAuthoritativeStateInitialization(AuthoritativeState state) + { + // PRODUCTION PERSISTENCE HOOK: atomically create-if-absent this complete + // serializable state, including sequence and high-water tick. A racing + // existing row or uncertain write must return false so startup remains + // closed. + return true; + } + + private bool TryHydrateAuthoritativeState(AuthoritativeState state) + { + if (state == null || + state.schemaVersion != 2 || + string.IsNullOrEmpty(state.runId) || + state.operationSequence < 0 || + state.lastAuthoritativeTick < 0 || + state.createRequest == null || + state.proposalAttempts == null || + state.appliedOperations == null || + state.reportOutbox == null) + return false; + + var expectedSessionId = "playthrough." + state.runId; + var expectedCreateRequest = BuildCreateRequest(state.runId); + if (state.createRequest.session_id != expectedSessionId || + state.createRequest.request_id != "create." + state.runId || + !SemanticDtoEquals(state.createRequest, expectedCreateRequest)) + return false; + + var restoredAttempts = new Dictionary(); + foreach (var saved in state.proposalAttempts) + { + if (saved == null || + saved.sessionId != expectedSessionId || + string.IsNullOrEmpty(saved.operationId) || + saved.sequence <= 0 || + saved.sequence != state.operationSequence || + !TryParseOperationSequence( + saved.operationId, + state.runId, + out var attemptOperationSequence) || + attemptOperationSequence != saved.sequence || + saved.request == null || + saved.request.session_id != expectedSessionId || + saved.request.request_id != "propose." + saved.operationId || + !SemanticDtoEquals( + saved.request, + BuildProposeRequest( + expectedSessionId, + saved.operationId, + saved.request.tick)) || + saved.request.tick < 0 || + saved.request.tick > state.lastAuthoritativeTick || + saved.fallbackActionId != "wait" || + saved.jobId == null || + (saved.jobId.Length > 0 && !RinClient.IsProtocolId(saved.jobId)) || + !SemanticDtoEquals(saved, new ProposalAttemptState + { + sessionId = expectedSessionId, + operationId = saved.operationId, + sequence = saved.sequence, + request = BuildProposeRequest( + expectedSessionId, + saved.operationId, + saved.request.tick), + fallbackActionId = "wait", + jobId = saved.jobId, + }) || + restoredAttempts.ContainsKey(saved.sessionId)) + return false; + restoredAttempts.Add( + saved.sessionId, + new ProposalAttempt( + saved.operationId, + saved.sequence, + saved.request, + saved.fallbackActionId, + saved.jobId ?? "")); + } + + var restoredApplied = new Dictionary(); + foreach (var saved in state.appliedOperations) + { + if (saved == null || + string.IsNullOrEmpty(saved.operationId) || + !TryParseOperationSequence( + saved.operationId, + state.runId, + out var appliedOperationSequence) || + appliedOperationSequence > state.operationSequence || + saved.actionId == null || + saved.outcome == null || + !SemanticDtoEquals(saved, new AppliedOperationState + { + operationId = saved.operationId, + actionId = saved.actionId, + accepted = saved.accepted, + outcome = saved.outcome, + }) || + restoredApplied.ContainsKey(saved.operationId)) + return false; + restoredApplied.Add( + saved.operationId, + new AppliedAction(saved.actionId ?? "", saved.accepted, saved.outcome ?? "")); + } + + var restoredOutbox = new Dictionary(); + foreach (var saved in state.reportOutbox) + { + if (saved == null || + string.IsNullOrEmpty(saved.operationId) || + !TryParseOperationSequence( + saved.operationId, + state.runId, + out var outboxOperationSequence) || + outboxOperationSequence > state.operationSequence || + !restoredApplied.ContainsKey(saved.operationId) || + (saved.kind != "commit" && saved.kind != "observe")) + return false; + PendingReport pending; + if (saved.kind == "commit") + { + if (saved.commit == null || + saved.fallback == null || + saved.observe != null || + saved.commit.request_id != "commit." + saved.operationId || + saved.commit.event_id != "outcome." + saved.operationId || + !RinClient.IsProtocolId(saved.commit.proposal_id) || + saved.commit.session_id != expectedSessionId || + saved.fallback.request_id != "reconcile." + saved.operationId || + saved.fallback.session_id != saved.commit.session_id || + saved.fallback.event_id != saved.commit.event_id || + saved.commit.tick < 0 || + saved.commit.tick > state.lastAuthoritativeTick || + saved.fallback.tick != saved.commit.tick || + saved.commit.accepted != restoredApplied[saved.operationId].accepted || + saved.commit.outcome != restoredApplied[saved.operationId].outcome || + !SemanticDtoEquals( + saved.commit, + BuildCommitRequest( + expectedSessionId, + saved.operationId, + saved.commit.proposal_id, + saved.commit.tick, + restoredApplied[saved.operationId])) || + !OutcomeObserveMatchesApplied( + saved.fallback, + restoredApplied[saved.operationId], + saved.operationId, + expectedSessionId)) + return false; + pending = PendingReport.Commit(saved.commit, saved.fallback); + } + else + { + if (saved.observe == null || + saved.commit != null || + saved.fallback != null || + saved.observe.session_id != expectedSessionId || + saved.observe.request_id != "reconcile." + saved.operationId || + (saved.observe.event_id != "fallback." + saved.operationId && + saved.observe.event_id != "outcome." + saved.operationId) || + saved.observe.tick < 0 || + saved.observe.tick > state.lastAuthoritativeTick || + !OutcomeObserveMatchesApplied( + saved.observe, + restoredApplied[saved.operationId], + saved.operationId, + expectedSessionId)) + return false; + pending = PendingReport.Observe(saved.observe); + } + if (restoredOutbox.ContainsKey(saved.operationId)) return false; + restoredOutbox.Add(saved.operationId, pending); + } + foreach (var attempt in restoredAttempts.Values) + { + if (restoredApplied.ContainsKey(attempt.operationId) || + restoredOutbox.ContainsKey(attempt.operationId)) + return false; + } + + runId = state.runId; + operationSequence = state.operationSequence; + lastAuthoritativeTick = state.lastAuthoritativeTick; + createRequest = state.createRequest; + proposalAttempts.Clear(); + appliedOperations.Clear(); + reportOutbox.Clear(); + foreach (var entry in restoredAttempts) proposalAttempts.Add(entry.Key, entry.Value); + foreach (var entry in restoredApplied) appliedOperations.Add(entry.Key, entry.Value); + foreach (var entry in restoredOutbox) reportOutbox.Add(entry.Key, entry.Value); + return true; + } + + private static bool TryParseOperationSequence( + string operationId, + string stableRunId, + out long sequence) + { + sequence = 0; + var prefix = stableRunId + "."; + if (string.IsNullOrEmpty(operationId) || + !operationId.StartsWith(prefix, StringComparison.Ordinal)) + return false; + var suffix = operationId.Substring(prefix.Length); + return long.TryParse( + suffix, + NumberStyles.None, + CultureInfo.InvariantCulture, + out sequence) && + sequence > 0 && + suffix == sequence.ToString(CultureInfo.InvariantCulture); + } + + private static bool OutcomeObserveMatchesApplied( + ObserveRequest observe, + AppliedAction applied, + string operationId, + string sessionId) + { + if (observe == null || applied == null || observe.source != "unity-example") + return false; + if (observe.event_id == "outcome." + operationId) + { + return observe.kind == "action_outcome" && + observe.summary == "Authoritative outcome: " + applied.outcome && + SemanticDtoEquals( + observe, + BuildOutcomeObserveRequest( + sessionId, + operationId, + observe.tick, + applied)); + } + if (observe.event_id == "fallback." + operationId) + { + return observe.kind == "fallback_action" && + observe.summary == + "Local fallback " + applied.actionId + ": " + applied.outcome && + SemanticDtoEquals( + observe, + BuildFallbackObserveRequest( + sessionId, + operationId, + observe.tick, + applied)); + } + return false; + } + + private static PendingReport BuildCommitReport( + string sessionId, + string operationId, + string proposalId, + long tick, + AppliedAction applied) + { + return PendingReport.Commit( + BuildCommitRequest(sessionId, operationId, proposalId, tick, applied), + BuildOutcomeObserveRequest(sessionId, operationId, tick, applied)); + } + + private static CommitRequest BuildCommitRequest( + string sessionId, + string operationId, + string proposalId, + long tick, + AppliedAction applied) + { + return new CommitRequest + { + protocol_version = RinClient.ProtocolVersion, + session_id = sessionId, + request_id = "commit." + operationId, + proposal_id = proposalId, + event_id = "outcome." + operationId, + tick = tick, + accepted = applied.accepted, + outcome = applied.outcome, + // Explicit nulls are the canonical defaults for this example. + // Restored non-null tags/facts/goal updates are rejected. + tags = null, + facts = null, + goal_updates = null, + }; + } + + private static ObserveRequest BuildOutcomeObserveRequest( + string sessionId, + string operationId, + long tick, + AppliedAction applied) + { + return new ObserveRequest + { + protocol_version = RinClient.ProtocolVersion, + session_id = sessionId, + request_id = "reconcile." + operationId, + event_id = "outcome." + operationId, + tick = tick, + // This example owns exactly npc.mira. A persisted or remote actor + // cannot redirect authoritative outcome memory to another observer. + observer_ids = new[] { "npc.mira" }, + source = "unity-example", + kind = "action_outcome", + summary = "Authoritative outcome: " + applied.outcome, + quote = null, + tags = new[] { "outcome-report" }, + importance = 3, + facts = null, + }; + } + + private static ObserveRequest BuildFallbackObserveRequest( + string sessionId, + string operationId, + long tick, + AppliedAction applied) + { + return new ObserveRequest + { + protocol_version = RinClient.ProtocolVersion, + session_id = sessionId, + request_id = "reconcile." + operationId, + event_id = "fallback." + operationId, + tick = tick, + observer_ids = new[] { "npc.mira" }, + source = "unity-example", + kind = "fallback_action", + summary = "Local fallback " + applied.actionId + ": " + applied.outcome, + quote = null, + tags = new[] { "fallback" }, + importance = 3, + facts = null, + }; + } + + private static CreateSessionRequest BuildCreateRequest(string stableRunId) + { + return new CreateSessionRequest + { + request_id = "create." + stableRunId, + session_id = "playthrough." + stableRunId, binding = new Binding { game_id = "example-game", @@ -25,6 +615,7 @@ private IEnumerator ProposeAndApply() content_hash = "example-content-hash", }, seed = 42, + features = new[] { "outcome-reporting-v1" }, actors = new[] { new ActorSeed @@ -46,50 +637,671 @@ private IEnumerator ProposeAndApply() status = "active", }, }, - think_every_ticks = 5, + think_every_ticks = NpcThinkEveryTicks, enabled = true, }, }, - }, value => created = value); - if (created == null) yield break; + }; + } - var request = new ProposeRequest + private static ProposeRequest BuildProposeRequest( + string sessionId, + string operationId, + long tick) + { + return new ProposeRequest { - session_id = "playthrough-1", - request_id = "propose.turn-19.mira", + session_id = sessionId, + request_id = "propose." + operationId, actor_id = "npc.mira", - tick = 19, + tick = tick, intent = "Choose how to respond to the player.", tags = new[] { "conversation", "trust" }, candidate_actions = new[] { - new ActionSpec { id = "talk", kind = "dialogue", description = "Ask one honest question." }, - new ActionSpec { id = "wait", kind = "wait", description = "Stay silent for now." }, + new ActionSpec + { + id = "talk", + kind = "dialogue", + description = "Ask one honest question.", + }, + new ActionSpec + { + id = "wait", + kind = "wait", + description = "Stay silent for now.", + }, }, }; - AdapterResult result = null; - yield return rin.ProposeWithFallback(request, "wait", value => result = value); - if (result == null || result.proposal == null) yield break; + } - ApplyActionInGame(result.proposal.action); - if (result.committable) + private static bool SemanticDtoEquals(object left, object right) + { + if (ReferenceEquals(left, right)) return true; + if (left == null || right == null || left.GetType() != right.GetType()) + return false; + var type = left.GetType(); + if (type.IsPrimitive || type.IsEnum || type == typeof(string) || + type == typeof(decimal)) + return left.Equals(right); + var leftDictionary = left as IDictionary; + var rightDictionary = right as IDictionary; + if (leftDictionary != null || rightDictionary != null) + { + if (leftDictionary == null || + rightDictionary == null || + leftDictionary.Count != rightDictionary.Count) + return false; + foreach (DictionaryEntry entry in leftDictionary) + { + if (!rightDictionary.Contains(entry.Key) || + !SemanticDtoEquals(entry.Value, rightDictionary[entry.Key])) + return false; + } + return true; + } + var leftList = left as IList; + var rightList = right as IList; + if (leftList != null || rightList != null) { - yield return rin.Commit(new CommitRequest + if (leftList == null || + rightList == null || + leftList.Count != rightList.Count) + return false; + for (var index = 0; index < leftList.Count; index++) { - session_id = request.session_id, - request_id = "commit.turn-19.mira", - proposal_id = result.proposal.id, - event_id = "event.turn-19.mira", - tick = request.tick, - accepted = true, - outcome = "The game applied the advertised action.", - }, _ => { }); + if (!SemanticDtoEquals(leftList[index], rightList[index])) + return false; + } + return true; } + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public)) + { + if (!SemanticDtoEquals(field.GetValue(left), field.GetValue(right))) + return false; + } + return true; } - private void ApplyActionInGame(ActionSpec action) + private bool PersistNewProposalAttempt( + string sessionId, + ProposalAttempt attempt, + long sequence, + long authoritativeTick) { - // Replace with navigation, animation, dialogue, or combat owned by Unity. - Debug.Log("Apply game-owned action: " + action.id); + if (!authoritativeStateReady) return false; + if (attempt == null || + attempt.request == null || + operationSequence == long.MaxValue || + sequence != operationSequence + 1 || + authoritativeTick <= lastAuthoritativeTick || + !TryParseOperationSequence(attempt.operationId, runId, out var parsedSequence) || + parsedSequence != sequence || + attempt.sequence != sequence || + attempt.request.session_id != sessionId || + attempt.request.request_id != "propose." + attempt.operationId || + !SemanticDtoEquals( + attempt.request, + BuildProposeRequest(sessionId, attempt.operationId, authoritativeTick)) || + attempt.fallbackActionId != "wait" || + attempt.request.tick != authoritativeTick) + return false; + // PRODUCTION PERSISTENCE HOOK: atomically save the complete attempt and + // consumed game sequence and lastAuthoritativeTick before any online + // submission or local fallback. + return true; + } + + private bool RecordProposalJobId( + string sessionId, + string operationId, + string jobId) + { + if (!RinClient.IsProtocolId(jobId) || + !proposalAttempts.TryGetValue(sessionId, out var attempt) || + attempt.operationId != operationId) + return false; + if (attempt.jobId == jobId) return true; + if (!PersistProposalJobId(sessionId, operationId, jobId)) return false; + attempt.jobId = jobId; + return true; + } + + private bool PersistProposalJobId( + string sessionId, + string operationId, + string jobId) + { + // PRODUCTION PERSISTENCE HOOK: durably attach the 202 Job ID to the + // matching stable attempt before the adapter starts polling it. + return true; + } + + private AppliedAction ApplyAndEnqueueAuthoritativeOperation( + string sessionId, + string operationId, + AppliedAction planned, + PendingReport report, + long proposalTick) + { + if (!authoritativeStateReady) return null; + if (appliedOperations.TryGetValue(operationId, out var stored)) + { + // Atomic persistence guarantees its report is still queued until + // acknowledgement. Never execute the game effect again. + return stored; + } + if (!PersistAuthoritativeTransaction( + sessionId, + operationId, + planned, + report, + proposalTick)) + return null; + return appliedOperations.TryGetValue(operationId, out var applied) + ? applied + : null; + } + + private bool PersistAuthoritativeTransaction( + string sessionId, + string operationId, + AppliedAction planned, + PendingReport report, + long proposalTick) + { + if (!authoritativeStateReady) return false; + // PRODUCTION PERSISTENCE HOOK: replace this whole body with one atomic + // game transaction. The actual Unity game-state effect, applied marker, + // complete report (including safe fallback), Proposal-attempt deletion, + // runId, sequence, and last authoritative tick must commit or roll back + // together. + if (!proposalAttempts.TryGetValue(sessionId, out var proposalAttempt) || + proposalAttempt.operationId != operationId) + return false; + if (proposalAttempt.request == null || + proposalAttempt.request.tick < 0 || + proposalTick < 0) + return false; + return RunAuthoritativeGameTransaction(transaction => + { + // Unity's frame counter may reset after a process or scene restart. + // Preserve the causal floor of the retained request and response. + var occurrenceTick = Math.Max( + Math.Max(CaptureAuthoritativeOccurrenceTick(), lastAuthoritativeTick), + Math.Max(proposalAttempt.request.tick, proposalTick)); + var effectivePlanned = planned; + if (planned.accepted && + occurrenceTick > long.MaxValue - NpcThinkEveryTicks) + { + // An accepted Commit schedules npc.mira at + // tick + think_every_ticks. Reject before applying any effect + // when that addition cannot fit in int64. + effectivePlanned = new AppliedAction( + planned.actionId, + false, + "The game rejected the action because the scheduler tick range is exhausted."); + } + var persistedReport = report + .WithAppliedOutcome(effectivePlanned) + .WithOccurrenceTick(occurrenceTick); + var previousLastTick = lastAuthoritativeTick; + lastAuthoritativeTick = occurrenceTick; + transaction.OnRollback(() => lastAuthoritativeTick = previousLastTick); + ApplyPlannedGameEffect(effectivePlanned, transaction); + appliedOperations.Add(operationId, effectivePlanned); + transaction.OnRollback(() => appliedOperations.Remove(operationId)); + reportOutbox.Add(operationId, persistedReport); + transaction.OnRollback(() => reportOutbox.Remove(operationId)); + // A succeeded online proposal (or confirmed-safe offline terminal) + // stops being resumable only in this authoritative transaction. + proposalAttempts.Remove(sessionId); + transaction.OnRollback(() => proposalAttempts[sessionId] = proposalAttempt); + return CommitAuthoritativeGameTransaction(operationId, occurrenceTick); + }); + } + + private IEnumerator FlushReportOutbox(Action completed) + { + if (!authoritativeStateReady) + { + completed(false); + yield break; + } + var operationIds = new List(reportOutbox.Keys); + operationIds.Sort(StringComparer.Ordinal); + foreach (var operationId in operationIds) + { + var pending = reportOutbox[operationId]; + MutationResult committed = null; + if (pending.kind == "commit") + { + ReportAttempt attempt = null; + yield return rin.CommitReport( + (CommitRequest)pending.request, + value => attempt = value); + if (attempt != null && attempt.ok) + { + committed = attempt.data; + } + else + { + var errorCode = attempt != null ? attempt.error_code : "unknown"; + if (!IsIrrecoverableCommitError(errorCode)) + { + Debug.LogError( + "Commit temporarily failed; its exact request remains queued."); + completed(false); + yield break; + } + var replacement = pending.AsFallbackObserve(); + if (!PersistReportConversion(operationId, replacement)) + { + Debug.LogError( + "Could not durably convert Commit; original remains queued."); + completed(false); + yield break; + } + reportOutbox[operationId] = replacement; + pending = replacement; + yield return rin.Observe( + (ObserveRequest)pending.request, + value => committed = value); + } + } + else if (pending.kind == "observe") + yield return rin.Observe((ObserveRequest)pending.request, value => committed = value); + else + { + Debug.LogError("Unknown authoritative report kind; entry remains queued."); + completed(false); + yield break; + } + if (committed == null) + { + Debug.LogError( + "Game action already handled; the same report remains queued for retry."); + completed(false); + yield break; + } + if (!PersistReportAcknowledgement(operationId)) + { + Debug.LogError( + "Report was acknowledged but durable Outbox deletion failed; retry is safe."); + completed(false); + yield break; + } + reportOutbox.Remove(operationId); + } + completed(true); + } + + private bool PersistReportAcknowledgement(string operationId) + { + // PRODUCTION PERSISTENCE HOOK: durably delete operationId's Outbox row. + // Only after true may the caller evict its in-memory copy. + return true; + } + + private bool PersistReportConversion(string operationId, PendingReport replacement) + { + // PRODUCTION PERSISTENCE HOOK: atomically replace the Commit row with + // replacement before changing the in-memory cache. + return true; + } + + private bool CommitAuthoritativeGameTransaction( + string operationId, + long authoritativeTick) + { + // PRODUCTION PERSISTENCE HOOK: false aborts effect, marker, Outbox, + // runId, sequence, and lastAuthoritativeTick together. + return authoritativeTick == lastAuthoritativeTick; + } + + private bool RunAuthoritativeGameTransaction(Func mutate) + { + var transaction = new GameTransaction(); + try + { + if (mutate(transaction)) return true; + } + catch (Exception error) + { + Debug.LogError("Authoritative game transaction failed: " + error.Message); + } + transaction.Rollback(); + return false; + } + + private long CaptureAuthoritativeOccurrenceTick() + { + // Read the current game clock inside the transaction at actual + // apply/reject. Production games should inject their persisted + // simulation clock here. + return Math.Max(0L, (long)Time.frameCount); + } + + private bool TryAllocateFreshProposalTick(out long tick) + { + tick = 0; + if (lastAuthoritativeTick == long.MaxValue) return false; + // Keep a larger live simulation clock; otherwise advance the restored + // durable high-water by one after a process/scene clock reset. + tick = Math.Max( + CaptureAuthoritativeOccurrenceTick(), + lastAuthoritativeTick + 1); + return true; + } + + private static bool ProposalIsFresh( + ProposalFreshnessResult state, + ActionProposal proposal, + ProposeRequest stableRequest) + { + if (state == null || proposal == null || stableRequest == null) + return false; + var retained = state.proposal; + ActionSpec stableAction = null; + if (stableRequest.candidate_actions != null && proposal.action != null) + { + foreach (var candidate in stableRequest.candidate_actions) + { + if (candidate != null && candidate.id == proposal.action.id) + { + stableAction = candidate; + break; + } + } + } + if (retained == null || + string.IsNullOrEmpty(retained.id) || + retained.id != proposal.id || + retained.status != "pending" || + string.IsNullOrEmpty(retained.session_id) || + retained.session_id != proposal.session_id || + string.IsNullOrEmpty(retained.request_id) || + retained.request_id != proposal.request_id || + string.IsNullOrEmpty(retained.actor_id) || + retained.actor_id != proposal.actor_id || + retained.tick < 0 || + retained.tick != proposal.tick || + retained.action == null || + proposal.action == null || + string.IsNullOrEmpty(retained.action.id) || + retained.action.id != proposal.action.id || + string.IsNullOrEmpty(retained.action.kind) || + retained.action.kind != proposal.action.kind || + !SemanticDtoEquals(retained.action, proposal.action) || + stableAction == null || + !SemanticDtoEquals(stableAction, proposal.action) || + retained.has_unsupported_action_parameters || + proposal.has_unsupported_action_parameters || + retained.based_on_revision < 0 || + retained.based_on_revision != proposal.based_on_revision || + retained.based_on_head_hash != proposal.based_on_head_hash || + retained.based_on_world_revision < 0 || + retained.based_on_world_revision != proposal.based_on_world_revision || + retained.created_revision < 0 || + retained.created_revision != proposal.created_revision) + return false; + return retained.based_on_world_revision > 0 + ? state.world_revision == retained.based_on_world_revision + : state.revision == retained.created_revision; + } + + private static bool IsIrrecoverableCommitError(string errorCode) + { + return errorCode == "session_not_found" || + errorCode == "unknown_proposal" || + errorCode == "proposal_resolved" || + errorCode == "proposal_canceled" || + errorCode == "proposal_stale"; + } + + private AppliedAction PlanActionInGame(ActionSpec action) + { + if (action == null || (action.id != "talk" && action.id != "wait")) + return new AppliedAction( + action != null ? action.id : "", + false, + "The game rejected an action outside its local allowlist."); + return new AppliedAction( + action.id, + true, + "The game applied the advertised action."); + } + + private void ApplyPlannedGameEffect( + AppliedAction planned, + GameTransaction transaction) + { + // Replace with navigation, animation, dialogue, or combat owned by + // Unity. Register the inverse before mutating; an exception then rolls + // the effect back with the marker and Outbox. + if (planned.accepted) + { + transaction.OnRollback( + () => Debug.Log("Roll back game-owned action: " + planned.actionId)); + Debug.Log("Apply game-owned action: " + planned.actionId); + } + } + + private enum AuthoritativeStateLoadStatus + { + Loaded, + NotFound, + Failed, + } + + private sealed class AuthoritativeStateLoadResult + { + public readonly AuthoritativeStateLoadStatus status; + public readonly AuthoritativeState state; + public readonly string error; + + private AuthoritativeStateLoadResult( + AuthoritativeStateLoadStatus status, + AuthoritativeState state, + string error) + { + this.status = status; + this.state = state; + this.error = error; + } + + public static AuthoritativeStateLoadResult Loaded(AuthoritativeState state) + { + return new AuthoritativeStateLoadResult( + AuthoritativeStateLoadStatus.Loaded, + state, + null); + } + + public static AuthoritativeStateLoadResult NotFound() + { + return new AuthoritativeStateLoadResult( + AuthoritativeStateLoadStatus.NotFound, + null, + null); + } + + public static AuthoritativeStateLoadResult Failed(string error) + { + return new AuthoritativeStateLoadResult( + AuthoritativeStateLoadStatus.Failed, + null, + error); + } + } + + // These DTOs intentionally avoid Dictionary and polymorphic object fields, + // so a game can serialize them with JsonUtility or its own save system. + [Serializable] + private sealed class AuthoritativeState + { + public int schemaVersion; + public string runId; + public long operationSequence; + public long lastAuthoritativeTick; + public CreateSessionRequest createRequest; + public ProposalAttemptState[] proposalAttempts; + public AppliedOperationState[] appliedOperations; + public PendingReportState[] reportOutbox; + } + + [Serializable] + private sealed class ProposalAttemptState + { + public string sessionId; + public string operationId; + public long sequence; + public ProposeRequest request; + public string fallbackActionId; + public string jobId; + } + + [Serializable] + private sealed class AppliedOperationState + { + public string operationId; + public string actionId; + public bool accepted; + public string outcome; + } + + [Serializable] + private sealed class PendingReportState + { + public string operationId; + public string kind; + public CommitRequest commit; + public ObserveRequest observe; + public ObserveRequest fallback; + } + + private sealed class GameTransaction + { + private readonly List rollbacks = new List(); + + public void OnRollback(Action rollback) + { + if (rollback != null) rollbacks.Add(rollback); + } + + public void Rollback() + { + for (var index = rollbacks.Count - 1; index >= 0; index--) + { + try + { + rollbacks[index](); + } + catch (Exception error) + { + Debug.LogError("Game rollback failed: " + error.Message); + } + } + } + } + + private sealed class AppliedAction + { + public readonly string actionId; + public readonly bool accepted; + public readonly string outcome; + + public AppliedAction(string actionId, bool accepted, string outcome) + { + this.actionId = actionId; + this.accepted = accepted; + this.outcome = outcome; + } + } + + private sealed class ProposalAttempt + { + public readonly string operationId; + public readonly long sequence; + public readonly ProposeRequest request; + public readonly string fallbackActionId; + public string jobId; + + public ProposalAttempt( + string operationId, + long sequence, + ProposeRequest request, + string fallbackActionId, + string jobId) + { + this.operationId = operationId; + this.sequence = sequence; + this.request = request; + this.fallbackActionId = fallbackActionId; + this.jobId = jobId; + } + } + + private sealed class PendingReport + { + public readonly string kind; + public readonly object request; + public readonly ObserveRequest fallback; + + private PendingReport(string kind, object request, ObserveRequest fallback) + { + this.kind = kind; + this.request = request; + this.fallback = fallback; + } + + public static PendingReport Commit( + CommitRequest request, + ObserveRequest fallback) + { + return new PendingReport("commit", request, fallback); + } + + public static PendingReport Observe(ObserveRequest request) + { + return new PendingReport("observe", request, null); + } + + public PendingReport WithAppliedOutcome(AppliedAction applied) + { + if (kind == "commit") + { + var commit = (CommitRequest)request; + commit.accepted = applied.accepted; + commit.outcome = applied.outcome; + fallback.summary = "Authoritative outcome: " + applied.outcome; + } + else + { + var observe = (ObserveRequest)request; + observe.summary = + "Local fallback " + applied.actionId + ": " + applied.outcome; + } + return this; + } + + public PendingReport WithOccurrenceTick(long tick) + { + if (kind == "commit") + { + ((CommitRequest)request).tick = tick; + fallback.tick = tick; + } + else + { + ((ObserveRequest)request).tick = tick; + } + return this; + } + + public PendingReport AsFallbackObserve() + { + return Observe(fallback); + } } } diff --git a/httpapi/server_test.go b/httpapi/server_test.go index ec5ed75..f8a96f1 100644 --- a/httpapi/server_test.go +++ b/httpapi/server_test.go @@ -104,6 +104,205 @@ func TestHTTPFlowAndNoSafeAction(t *testing.T) { } } +func TestCommitHTTPReportsOutcomeAfterSessionAdvances(t *testing.T) { + server := newServer(t, httpapi.Options{}) + create := apiCreateRequest() + create.Features = []string{protocol.FeatureOutcomeReporting} + if response := perform(t, server, "/v1/session/create", create); response.Code != http.StatusOK { + t.Fatalf("create: %d %s", response.Code, response.Body.String()) + } + proposeResponse := perform(t, server, "/v1/agent/propose", protocol.ProposeRequest{ + ProtocolVersion: protocol.Version, + SessionID: "session.http", + RequestID: "propose.outcome-report", + ActorID: "npc.http", + Tick: 0, + Intent: "Wait for the game authority.", + CandidateActions: []protocol.ActionSpec{{ + ID: "wait", Kind: "wait", Description: "wait", + }}, + }) + if proposeResponse.Code != http.StatusOK { + t.Fatalf("propose: %d %s", proposeResponse.Code, proposeResponse.Body.String()) + } + var proposed struct { + OK bool `json:"ok"` + Data protocol.ProposalResult `json:"data"` + } + if err := json.Unmarshal(proposeResponse.Body.Bytes(), &proposed); err != nil { + t.Fatal(err) + } + if !proposed.OK || proposed.Data.Proposal.ID == "" { + t.Fatalf("unexpected proposal response: %+v", proposed) + } + if response := perform(t, server, "/v1/session/observe", protocol.ObserveRequest{ + ProtocolVersion: protocol.Version, + SessionID: "session.http", + RequestID: "observe.after-apply", + EventID: "event.after-apply", + Tick: 5, + ObserverIDs: []string{"npc.http"}, + Source: "game", + Kind: "world", + Summary: "The authoritative game state advanced.", + Importance: 1, + }); response.Code != http.StatusOK { + t.Fatalf("observe: %d %s", response.Code, response.Body.String()) + } + commitResponse := perform(t, server, "/v1/action/commit", protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: "session.http", + RequestID: "commit.outcome-report", + ProposalID: proposed.Data.Proposal.ID, + EventID: "event.outcome-report", + Tick: 0, + Accepted: true, + Outcome: "The game had already applied this action.", + }) + if commitResponse.Code != http.StatusOK { + t.Fatalf("late outcome report: %d %s", commitResponse.Code, commitResponse.Body.String()) + } + assertResponseOK(t, commitResponse) +} + +func TestBatchCommitHTTPHandlesLateAndMixedBaseOutcomes(t *testing.T) { + t.Run("late outcome", func(t *testing.T) { + server := newServer(t, httpapi.Options{}) + create := apiCreateRequest() + create.Features = []string{protocol.FeatureArbitration, protocol.FeatureOutcomeReporting} + if response := perform(t, server, "/v1/session/create", create); response.Code != http.StatusOK { + t.Fatalf("create: %d %s", response.Code, response.Body.String()) + } + proposal := proposeHTTP(t, server, protocol.ProposeRequest{ + ProtocolVersion: protocol.Version, + SessionID: create.SessionID, + RequestID: "propose.batch-http-late", + ActorID: "npc.http", + Tick: 0, + Intent: "Wait for the game authority.", + CandidateActions: []protocol.ActionSpec{{ + ID: "wait", Kind: "wait", Description: "wait", + }}, + }) + if response := perform(t, server, "/v1/session/observe", protocol.ObserveRequest{ + ProtocolVersion: protocol.Version, + SessionID: create.SessionID, + RequestID: "observe.batch-http-advance", + EventID: "event.batch-http-advance", + Tick: 5, + ObserverIDs: []string{"npc.http"}, + Source: "game", + Kind: "world", + Summary: "The authoritative game state advanced.", + Importance: 1, + }); response.Code != http.StatusOK { + t.Fatalf("observe: %d %s", response.Code, response.Body.String()) + } + response := perform(t, server, "/v1/action/commit-batch", protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: create.SessionID, + RequestID: "commit.batch-http-late", + Tick: 0, + Items: []protocol.CommitItem{{ + ProposalID: proposal.ID, + EventID: "event.batch-http-late", + Accepted: true, + Outcome: "The game had already applied this batch item.", + }}, + }) + if response.Code != http.StatusOK { + t.Fatalf("late batch outcome: %d %s", response.Code, response.Body.String()) + } + assertResponseOK(t, response) + + stateResponse := perform(t, server, "/v1/session/get", protocol.SessionRequest{ + ProtocolVersion: protocol.Version, + SessionID: create.SessionID, + }) + var stateEnvelope struct { + Data protocol.SessionState `json:"data"` + } + if err := json.Unmarshal(stateResponse.Body.Bytes(), &stateEnvelope); err != nil { + t.Fatal(err) + } + if stateEnvelope.Data.Tick != 5 || + stateEnvelope.Data.Proposals[proposal.ID].Status != "accepted" { + t.Fatalf("late batch regressed HTTP state: %+v", stateEnvelope.Data) + } + }) + + t.Run("mixed bases", func(t *testing.T) { + server := newServer(t, httpapi.Options{}) + create := apiCreateRequest() + create.SessionID = "session.http-mixed-base" + create.RequestID = "create.http-mixed-base" + create.Features = []string{protocol.FeatureArbitration, protocol.FeatureOutcomeReporting} + other := create.Actors[0] + other.ID = "npc.other" + other.DisplayName = "Other HTTP NPC" + create.Actors = append(create.Actors, other) + if response := perform(t, server, "/v1/session/create", create); response.Code != http.StatusOK { + t.Fatalf("create: %d %s", response.Code, response.Body.String()) + } + older := proposeHTTP(t, server, protocol.ProposeRequest{ + ProtocolVersion: protocol.Version, + SessionID: create.SessionID, + RequestID: "propose.http-base-one", + ActorID: "npc.http", + Tick: 0, + Intent: "Wait.", + CandidateActions: []protocol.ActionSpec{{ + ID: "wait", Kind: "wait", Description: "wait", + }}, + }) + if response := perform(t, server, "/v1/session/observe", protocol.ObserveRequest{ + ProtocolVersion: protocol.Version, + SessionID: create.SessionID, + RequestID: "observe.http-new-base", + EventID: "event.http-new-base", + Tick: 5, + ObserverIDs: []string{"npc.http", "npc.other"}, + Source: "game", + Kind: "world", + Summary: "The authoritative world revision advanced.", + Importance: 1, + }); response.Code != http.StatusOK { + t.Fatalf("observe: %d %s", response.Code, response.Body.String()) + } + newer := proposeHTTP(t, server, protocol.ProposeRequest{ + ProtocolVersion: protocol.Version, + SessionID: create.SessionID, + RequestID: "propose.http-base-two", + ActorID: "npc.other", + Tick: 5, + Intent: "Wait.", + CandidateActions: []protocol.ActionSpec{{ + ID: "wait", Kind: "wait", Description: "wait", + }}, + }) + response := perform(t, server, "/v1/action/commit-batch", protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: create.SessionID, + RequestID: "commit.http-mixed-base", + Tick: 5, + Items: []protocol.CommitItem{ + {ProposalID: older.ID, EventID: "event.http-old-base", Accepted: true, Outcome: "Old base."}, + {ProposalID: newer.ID, EventID: "event.http-new-base-outcome", Accepted: true, Outcome: "New base."}, + }, + }) + if response.Code != http.StatusConflict { + t.Fatalf("mixed-base batch: %d %s", response.Code, response.Body.String()) + } + var envelope protocol.APIResponse + if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if envelope.Error == nil || envelope.Error.Code != "proposal_base_mismatch" { + t.Fatalf("unexpected mixed-base error: %+v", envelope.Error) + } + }) +} + func TestTimelineAndReplayHTTPFlow(t *testing.T) { server := newServer(t, httpapi.Options{}) if response := perform(t, server, "/v1/session/create", apiCreateRequest()); response.Code != http.StatusOK { @@ -325,6 +524,25 @@ func assertResponseOK(t *testing.T, response *httptest.ResponseRecorder) { } } +func proposeHTTP(t *testing.T, handler http.Handler, request protocol.ProposeRequest) protocol.ActionProposal { + t.Helper() + response := perform(t, handler, "/v1/agent/propose", request) + if response.Code != http.StatusOK { + t.Fatalf("propose: %d %s", response.Code, response.Body.String()) + } + var envelope struct { + OK bool `json:"ok"` + Data protocol.ProposalResult `json:"data"` + } + if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if !envelope.OK || envelope.Data.Proposal.ID == "" { + t.Fatalf("unexpected proposal response: %+v", envelope) + } + return envelope.Data.Proposal +} + func apiCreateRequest() protocol.CreateSessionRequest { return protocol.CreateSessionRequest{ ProtocolVersion: protocol.Version, diff --git a/jobs/manager.go b/jobs/manager.go index ee32859..7314d13 100644 --- a/jobs/manager.go +++ b/jobs/manager.go @@ -49,6 +49,7 @@ type jobState struct { requestHash string cancel context.CancelFunc ctx context.Context + done chan struct{} completedAt time.Time } @@ -115,6 +116,41 @@ func (m *Manager) Submit(request protocol.ProposeRequest) (protocol.ProposalJobS if existing.requestHash != requestHash { return protocol.ProposalJobSubmission{}, rinruntime.NewFieldError("request_id_conflict", "request id was already used with a different proposal payload", "request_id", rinruntime.ErrConflict) } + if proposalOutcomeUnknown(existing.public) { + // An unknown outcome is not a reusable terminal answer. Re-submitting + // the exact identity asks Engine.Propose to reconcile the durable + // event. Replace the attempt instead of mutating it so a Cancel call + // already waiting on the old done channel still observes that + // attempt's final result. + select { + case m.queue <- existingID: + existing.cancel() + jobContext, cancel := context.WithCancel(m.ctx) + m.jobs[existingID] = &jobState{ + public: protocol.ProposalJob{ + ProtocolVersion: protocol.Version, + JobID: existingID, + SessionID: request.SessionID, + RequestID: request.RequestID, + Status: "queued", + SubmittedAt: now.UTC().Format(time.RFC3339Nano), + }, + request: request, + requestHash: requestHash, + cancel: cancel, + ctx: jobContext, + done: make(chan struct{}), + } + return protocol.ProposalJobSubmission{ + ProtocolVersion: protocol.Version, + JobID: existingID, + Status: "queued", + Duplicate: true, + }, nil + default: + return protocol.ProposalJobSubmission{}, rinruntime.NewError("jobs_queue_full", "proposal job queue is full", ErrQueueFull) + } + } return protocol.ProposalJobSubmission{ ProtocolVersion: protocol.Version, JobID: existing.public.JobID, Status: existing.public.Status, Duplicate: true, }, nil @@ -130,6 +166,7 @@ func (m *Manager) Submit(request protocol.ProposeRequest) (protocol.ProposalJobS RequestID: request.RequestID, Status: "queued", SubmittedAt: now.UTC().Format(time.RFC3339Nano), }, request: request, requestHash: requestHash, cancel: cancel, ctx: jobContext, + done: make(chan struct{}), } m.jobs[jobID] = state m.byRequest[requestKey] = jobID @@ -156,21 +193,41 @@ func (m *Manager) Get(jobID string) (protocol.ProposalJob, error) { func (m *Manager) Cancel(jobID string) (protocol.ProposalJob, error) { m.mu.Lock() - defer m.mu.Unlock() state, exists := m.jobs[jobID] if !exists { + m.mu.Unlock() return protocol.ProposalJob{}, rinruntime.NewFieldError("job_not_found", "proposal job does not exist", "job_id", rinruntime.ErrNotFound) } if terminal(state.public.Status) { - return cloneJob(state.public), nil + result := cloneJob(state.public) + m.mu.Unlock() + return result, nil } state.cancel() - now := m.now() - state.public.Status = "canceled" - state.public.FinishedAt = now.UTC().Format(time.RFC3339Nano) - state.public.Error = &protocol.ErrorDetail{Code: "job_canceled", Message: "proposal job was canceled"} - state.completedAt = now - return cloneJob(state.public), nil + if state.public.Status == "queued" { + now := m.now() + state.public.Status = "canceled" + state.public.FinishedAt = now.UTC().Format(time.RFC3339Nano) + state.public.Error = &protocol.ErrorDetail{Code: "job_canceled", Message: "proposal job was canceled"} + state.completedAt = now + close(state.done) + result := cloneJob(state.public) + m.mu.Unlock() + return result, nil + } + done := state.done + m.mu.Unlock() + + // A running Engine.Propose may already be inside its durable append after + // the final context check. Wait for the worker to publish the truth: + // either cancellation prevented the event, or a persisted Proposal won the + // race and must be returned instead of hidden behind a transient canceled + // response. + <-done + m.mu.Lock() + result := cloneJob(state.public) + m.mu.Unlock() + return result, nil } func (m *Manager) Close(ctx context.Context) error { @@ -180,12 +237,15 @@ func (m *Manager) Close(ctx context.Context) error { m.cancel() now := m.now() for _, state := range m.jobs { - if !terminal(state.public.Status) { + if state.public.Status == "queued" { state.cancel() state.public.Status = "canceled" state.public.FinishedAt = now.UTC().Format(time.RFC3339Nano) state.public.Error = &protocol.ErrorDetail{Code: "jobs_closed", Message: "proposal job manager stopped"} state.completedAt = now + close(state.done) + } else if state.public.Status == "running" { + state.cancel() } } } @@ -238,6 +298,8 @@ func (m *Manager) run(jobID string) { state.public.Error = nil } else if state.public.Status != "canceled" { switch { + case rinruntime.ErrorCode(err) == "proposal_outcome_unknown": + state.public.Status = "failed" case errors.Is(err, rinruntime.ErrStale): state.public.Status = "stale" case errors.Is(err, context.Canceled): @@ -249,6 +311,7 @@ func (m *Manager) run(jobID string) { } state.public.FinishedAt = now.UTC().Format(time.RFC3339Nano) state.completedAt = now + close(state.done) } func (m *Manager) cleanup(now time.Time) { @@ -298,12 +361,18 @@ func hashRequest(request protocol.ProposeRequest) (string, error) { func jobError(err error) *protocol.ErrorDetail { code := rinruntime.ErrorCode(err) - if errors.Is(err, context.Canceled) { + if code != "proposal_outcome_unknown" && errors.Is(err, context.Canceled) { code = "job_canceled" } return &protocol.ErrorDetail{Code: code, Message: err.Error(), Field: rinruntime.ErrorField(err)} } +func proposalOutcomeUnknown(job protocol.ProposalJob) bool { + return job.Status == "failed" && + job.Error != nil && + job.Error.Code == "proposal_outcome_unknown" +} + func terminal(status string) bool { return status == "succeeded" || status == "failed" || status == "stale" || status == "canceled" } diff --git a/jobs/manager_test.go b/jobs/manager_test.go index 1bf8157..68788e6 100644 --- a/jobs/manager_test.go +++ b/jobs/manager_test.go @@ -53,6 +53,143 @@ func TestProposalJobCancellation(t *testing.T) { } } +func TestProposalJobCancelWaitsForPersistedProposal(t *testing.T) { + eventStore := newBlockingProposalAppendStore(store.NewMemory()) + engine, err := rinruntime.Open(eventStore, policy.Deterministic{}) + if err != nil { + t.Fatal(err) + } + const sessionID = "session.cancel-persist-race" + if _, err := engine.CreateSession(protocol.CreateSessionRequest{ + ProtocolVersion: protocol.Version, + RequestID: "create." + sessionID, + SessionID: sessionID, + Binding: protocol.Binding{ + GameID: "game.jobs", ContentID: "base", ContentVersion: "1", ContentHash: "hash", + }, + Actors: []protocol.ActorSeed{{ + ID: "npc.jobs", Kind: "npc", DisplayName: "Jobs NPC", Enabled: true, ThinkEveryTicks: 1, + Goals: []protocol.Goal{{ + ID: "goal.jobs", Description: "Respond", Priority: 1, + PreferredActions: []string{"talk"}, TargetProgress: 2, Status: "active", + }}, + }}, + }); err != nil { + t.Fatal(err) + } + manager := jobManager(t, engine, jobs.Config{Workers: 1, QueueSize: 2, MaxJobs: 4}) + defer closeManager(t, manager) + defer eventStore.release() + + submission, err := manager.Submit(jobRequest(sessionID, "request.cancel-persist-race")) + if err != nil { + t.Fatal(err) + } + eventStore.waitStarted(t) + type cancelResult struct { + job protocol.ProposalJob + err error + } + resultChannel := make(chan cancelResult, 1) + go func() { + job, cancelErr := manager.Cancel(submission.JobID) + resultChannel <- cancelResult{job: job, err: cancelErr} + }() + select { + case result := <-resultChannel: + t.Fatalf("Cancel returned before the durable Proposal settled: %+v err=%v", result.job, result.err) + case <-time.After(25 * time.Millisecond): + } + + eventStore.release() + select { + case result := <-resultChannel: + if result.err != nil { + t.Fatal(result.err) + } + if result.job.Status != "succeeded" || result.job.Proposal == nil { + t.Fatalf("Cancel hid a Proposal that won the persistence race: %+v", result.job) + } + case <-time.After(time.Second): + t.Fatal("Cancel did not return after the Proposal append completed") + } +} + +func TestProposalJobExposesUnknownOutcomeAndSameRequestRecovers(t *testing.T) { + eventStore := newUnknownProposalAppendStore(store.NewMemory()) + engine, err := rinruntime.Open(eventStore, policy.Deterministic{}) + if err != nil { + t.Fatal(err) + } + const sessionID = "session.jobs-unknown" + if _, err := engine.CreateSession(protocol.CreateSessionRequest{ + ProtocolVersion: protocol.Version, + RequestID: "create." + sessionID, + SessionID: sessionID, + Binding: protocol.Binding{ + GameID: "game.jobs", ContentID: "base", ContentVersion: "1", ContentHash: "hash", + }, + Actors: []protocol.ActorSeed{{ + ID: "npc.jobs", Kind: "npc", DisplayName: "Jobs NPC", Enabled: true, ThinkEveryTicks: 1, + Goals: []protocol.Goal{{ + ID: "goal.jobs", Description: "Respond", Priority: 1, + PreferredActions: []string{"talk"}, TargetProgress: 2, Status: "active", + }}, + }}, + }); err != nil { + t.Fatal(err) + } + manager := jobManager(t, engine, jobs.Config{Workers: 1, QueueSize: 4, MaxJobs: 8}) + defer closeManager(t, manager) + request := jobRequest(sessionID, "request.jobs-unknown") + + eventStore.failPostWriteAndConfirmation() + submission, err := manager.Submit(request) + if err != nil { + t.Fatal(err) + } + failed := waitJob(t, manager, submission.JobID) + if failed.Status != "failed" || failed.Error == nil || + failed.Error.Code != "proposal_outcome_unknown" { + t.Fatalf("GET hid the uncertain Proposal outcome: %+v", failed) + } + fromGet, err := manager.Get(submission.JobID) + if err != nil { + t.Fatal(err) + } + if fromGet.Error == nil || fromGet.Error.Code != "proposal_outcome_unknown" { + t.Fatalf("GET error code = %+v, want proposal_outcome_unknown", fromGet.Error) + } + fromCancel, err := manager.Cancel(submission.JobID) + if err != nil { + t.Fatal(err) + } + if fromCancel.Status != "failed" || fromCancel.Error == nil || + fromCancel.Error.Code != "proposal_outcome_unknown" { + t.Fatalf("Cancel hid the uncertain Proposal outcome: %+v", fromCancel) + } + + retry, err := manager.Submit(request) + if err != nil { + t.Fatalf("same request should be allowed to coordinate recovery: %v", err) + } + if !retry.Duplicate || retry.JobID != submission.JobID || retry.Status != "queued" { + t.Fatalf("unexpected recovery submission: first=%+v retry=%+v", submission, retry) + } + recovered := waitJob(t, manager, retry.JobID) + if recovered.Status != "succeeded" || recovered.Proposal == nil || + recovered.Proposal.RequestID != request.RequestID || recovered.Error != nil { + t.Fatalf("same-request recovery did not return the persisted Proposal: %+v", recovered) + } + events, err := eventStore.Load(sessionID) + if err != nil { + t.Fatal(err) + } + if len(events) != 2 || events[1].Type != rinruntime.EventProposed { + t.Fatalf("same-request recovery should retain one Proposal event: %+v", events) + } +} + func TestProposalJobBecomesStaleWhenStateChanges(t *testing.T) { blocking := newBlockingPolicy() engine := jobEngine(t, blocking, "session.stale-job") @@ -110,6 +247,86 @@ type blockingPolicy struct { once sync.Once } +type blockingProposalAppendStore struct { + rinruntime.Store + + started chan struct{} + releaseOnce sync.Once + startOnce sync.Once + releaseCh chan struct{} +} + +type unknownProposalAppendStore struct { + rinruntime.Store + + mu sync.Mutex + failStage int +} + +func newBlockingProposalAppendStore(delegate rinruntime.Store) *blockingProposalAppendStore { + return &blockingProposalAppendStore{ + Store: delegate, started: make(chan struct{}), releaseCh: make(chan struct{}), + } +} + +func newUnknownProposalAppendStore(delegate rinruntime.Store) *unknownProposalAppendStore { + return &unknownProposalAppendStore{Store: delegate} +} + +func (s *blockingProposalAppendStore) Append(sessionID string, event protocol.EventRecord) error { + if event.Type == rinruntime.EventProposed { + s.startOnce.Do(func() { close(s.started) }) + <-s.releaseCh + } + return s.Store.Append(sessionID, event) +} + +func (s *unknownProposalAppendStore) failPostWriteAndConfirmation() { + s.mu.Lock() + s.failStage = 1 + s.mu.Unlock() +} + +func (s *unknownProposalAppendStore) Append(sessionID string, event protocol.EventRecord) error { + if event.Type != rinruntime.EventProposed { + return s.Store.Append(sessionID, event) + } + s.mu.Lock() + stage := s.failStage + if stage == 1 { + s.failStage = 2 + } else if stage == 2 { + s.failStage = 0 + } + s.mu.Unlock() + switch stage { + case 1: + if err := s.Store.Append(sessionID, event); err != nil { + return err + } + return errUnknownProposalAppend + case 2: + return errUnknownProposalAppend + default: + return s.Store.Append(sessionID, event) + } +} + +func (s *blockingProposalAppendStore) waitStarted(t *testing.T) { + t.Helper() + select { + case <-s.started: + case <-time.After(time.Second): + t.Fatal("proposal append did not start") + } +} + +func (s *blockingProposalAppendStore) release() { + s.releaseOnce.Do(func() { close(s.releaseCh) }) +} + +var errUnknownProposalAppend = errors.New("injected uncertain Proposal append") + func newBlockingPolicy() *blockingPolicy { return &blockingPolicy{started: make(chan struct{}), releaseChannel: make(chan struct{})} } diff --git a/protocol/features.go b/protocol/features.go index 08aa252..6cddfe3 100644 --- a/protocol/features.go +++ b/protocol/features.go @@ -8,14 +8,20 @@ const ( FeatureGoalCandidates = "goal-candidates-v1" FeatureActorActivity = "actor-activity-v1" FeatureArbitration = "arbitration-v1" + // FeatureOutcomeReporting opts a session into game-authoritative outcome + // reports, late occurrence-time merging, and durable outcome metadata. + // Sessions created before this feature retain their historical reducer + // semantics when old event logs are replayed. + FeatureOutcomeReporting = "outcome-reporting-v1" ) var supportedFeatures = map[string]struct{}{ - FeatureMemoryArchive: {}, - FeatureBeliefConflicts: {}, - FeatureGoalCandidates: {}, - FeatureActorActivity: {}, - FeatureArbitration: {}, + FeatureMemoryArchive: {}, + FeatureBeliefConflicts: {}, + FeatureGoalCandidates: {}, + FeatureActorActivity: {}, + FeatureArbitration: {}, + FeatureOutcomeReporting: {}, } func SupportedFeatures() []string { diff --git a/protocol/living.go b/protocol/living.go index 550ed5c..8a551e2 100644 --- a/protocol/living.go +++ b/protocol/living.go @@ -54,6 +54,7 @@ type ArbitrationResult struct { Duplicate bool `json:"duplicate"` } +// CommitItem reports one authoritative game outcome in a BatchCommitRequest. type CommitItem struct { ProposalID string `json:"proposal_id"` EventID string `json:"event_id"` @@ -64,6 +65,9 @@ type CommitItem struct { GoalUpdates []GoalUpdate `json:"goal_updates,omitempty"` } +// BatchCommitRequest records outcomes for proposals that were produced from +// one world revision. That base may be older than Rin's head when the report +// arrives because the game applies the outcomes before reporting them. type BatchCommitRequest struct { ProtocolVersion string `json:"protocol_version"` SessionID string `json:"session_id"` diff --git a/protocol/living_validate.go b/protocol/living_validate.go index e2de1a8..6b996e6 100644 --- a/protocol/living_validate.go +++ b/protocol/living_validate.go @@ -114,7 +114,7 @@ func validateCommitItem(field string, item CommitItem) error { return &ValidationError{Field: field, Message: "contains too many updates"} } for index, fact := range item.Facts { - if err := validateFact(fmt.Sprintf("%s.facts[%d]", field, index), fact); err != nil { + if err := validateRequestFact(fmt.Sprintf("%s.facts[%d]", field, index), fact); err != nil { return err } } diff --git a/protocol/state_validate.go b/protocol/state_validate.go index 9ff3349..7693370 100644 --- a/protocol/state_validate.go +++ b/protocol/state_validate.go @@ -35,6 +35,7 @@ func ValidateSessionState(state SessionState) error { if !hashPattern.MatchString(state.HeadHash) { return &ValidationError{Field: "state.head_hash", Message: "must be a lowercase SHA-256 hash"} } + outcomeReporting := HasFeature(state.Features, FeatureOutcomeReporting) if len(state.Actors) == 0 || len(state.Actors) > 128 { return &ValidationError{Field: "state.actors", Message: "must contain 1-128 actors"} } @@ -46,6 +47,52 @@ func ValidateSessionState(state SessionState) error { if err := validateActor(base, actor.ActorSeed); err != nil { return err } + for index, goal := range actor.Goals { + if !outcomeReporting && + (goal.UpdatedTick != 0 || + goal.ProgressAccumulator != 0 || + goal.StatusExplicit || + goal.StatusUpdatedTick != 0 || + goal.StatusSourceEventID != "") { + return &ValidationError{ + Field: fmt.Sprintf("%s.goals[%d]", base, index), + Message: "outcome occurrence metadata requires outcome-reporting-v1", + } + } + if goal.UpdatedTick > state.Tick { + return &ValidationError{ + Field: fmt.Sprintf("%s.goals[%d].updated_tick", base, index), + Message: "must not exceed the session tick", + } + } + if goal.StatusUpdatedTick > goal.UpdatedTick { + return &ValidationError{ + Field: fmt.Sprintf("%s.goals[%d].status_updated_tick", base, index), + Message: "must not exceed updated_tick", + } + } + if !goal.StatusExplicit && + (goal.StatusUpdatedTick != 0 || goal.StatusSourceEventID != "") { + return &ValidationError{ + Field: fmt.Sprintf("%s.goals[%d]", base, index), + Message: "automatic status cannot carry explicit status metadata", + } + } + if outcomeReporting { + expected := goal.ProgressAccumulator + if expected < 0 { + expected = 0 + } else if expected > int64(goal.TargetProgress) { + expected = int64(goal.TargetProgress) + } + if int64(goal.Progress) != expected { + return &ValidationError{ + Field: fmt.Sprintf("%s.goals[%d].progress", base, index), + Message: "must be the bounded projection of progress_accumulator", + } + } + } + } if actor.NextThinkTick < 0 { return &ValidationError{Field: base + ".next_think_tick", Message: "must not be negative"} } @@ -98,6 +145,12 @@ func ValidateSessionState(state SessionState) error { if err := validateFact(field, fact); err != nil { return err } + if fact.ObservedTick > state.Tick { + return &ValidationError{Field: field + ".observed_tick", Message: "must not exceed the session tick"} + } + if !outcomeReporting && fact.ObservedTick != 0 { + return &ValidationError{Field: field + ".observed_tick", Message: "requires outcome-reporting-v1"} + } for _, visibleActor := range fact.Visibility { if _, exists := state.Actors[visibleActor]; !exists { return &ValidationError{Field: field + ".visibility", Message: "references an unknown actor"} @@ -112,7 +165,7 @@ func ValidateSessionState(state SessionState) error { } for key, set := range actor.BeliefSets { field := base + ".belief_sets." + key - if err := validateBeliefSet(field, key, set, state.Revision); err != nil { + if err := validateBeliefSet(field, key, set, state.Revision, state.Tick, outcomeReporting); err != nil { return err } selected, exists := actor.Beliefs[key] @@ -196,6 +249,12 @@ func ValidateSessionState(state SessionState) error { return err } } + if receipt.RequestHash != "" && !hashPattern.MatchString(receipt.RequestHash) { + return &ValidationError{ + Field: field + ".request_hash", + Message: "must be a lowercase SHA-256 digest", + } + } } return nil } @@ -262,7 +321,14 @@ func validateMemorySummary(field string, summary MemorySummary) error { return nil } -func validateBeliefSet(field, key string, set BeliefSet, stateRevision uint64) error { +func validateBeliefSet( + field string, + key string, + set BeliefSet, + stateRevision uint64, + stateTick int64, + outcomeReporting bool, +) error { if err := validateID(field+".subject_id", set.SubjectID); err != nil { return err } @@ -286,6 +352,12 @@ func validateBeliefSet(field, key string, set BeliefSet, stateRevision uint64) e if err := validateFact(claimField+".fact", claim.Fact); err != nil { return err } + if claim.Fact.ObservedTick > stateTick { + return &ValidationError{Field: claimField + ".fact.observed_tick", Message: "must not exceed the session tick"} + } + if !outcomeReporting && claim.Fact.ObservedTick != 0 { + return &ValidationError{Field: claimField + ".fact.observed_tick", Message: "requires outcome-reporting-v1"} + } if claim.Fact.SubjectID != set.SubjectID || claim.Fact.Predicate != set.Predicate { return &ValidationError{Field: claimField + ".fact", Message: "must match its belief set"} } @@ -394,6 +466,28 @@ func validateProposal(field string, state SessionState, actor ActorState, propos if proposal.Tick < 0 { return &ValidationError{Field: field + ".tick", Message: "must not be negative"} } + outcomeReporting := HasFeature(state.Features, FeatureOutcomeReporting) + if !outcomeReporting && (proposal.OutcomeEventID != "" || proposal.OutcomeTick != 0) { + return &ValidationError{Field: field, Message: "outcome occurrence metadata requires outcome-reporting-v1"} + } + if proposal.OutcomeEventID == "" { + if proposal.OutcomeTick != 0 { + return &ValidationError{Field: field + ".outcome_tick", Message: "requires outcome_event_id"} + } + if outcomeReporting && proposal.Status != "pending" { + return &ValidationError{Field: field + ".outcome_event_id", Message: "resolved proposals require outcome metadata"} + } + } else { + if err := validateID(field+".outcome_event_id", proposal.OutcomeEventID); err != nil { + return err + } + if proposal.Status == "pending" { + return &ValidationError{Field: field + ".outcome_event_id", Message: "pending proposals cannot have an outcome"} + } + if proposal.OutcomeTick < proposal.Tick || proposal.OutcomeTick > state.Tick { + return &ValidationError{Field: field + ".outcome_tick", Message: "must be between proposal tick and session tick"} + } + } if !hashPattern.MatchString(proposal.BasedOnHeadHash) { return &ValidationError{Field: field + ".based_on_head_hash", Message: "must be a lowercase SHA-256 hash"} } @@ -442,8 +536,15 @@ func validateProposal(field string, state SessionState, actor ActorState, propos if err := validateGoal(field+".proposed_goal", *proposal.ProposedGoal); err != nil { return err } - if proposal.ProposedGoal.ID != proposal.GoalID || proposal.ProposedGoal.Progress != 0 || proposal.ProposedGoal.Status != "active" { - return &ValidationError{Field: field + ".proposed_goal", Message: "must match an active zero-progress goal_id"} + if proposal.ProposedGoal.ID != proposal.GoalID || + proposal.ProposedGoal.Progress != 0 || + proposal.ProposedGoal.Status != "active" || + proposal.ProposedGoal.UpdatedTick != 0 || + proposal.ProposedGoal.ProgressAccumulator != 0 || + proposal.ProposedGoal.StatusExplicit || + proposal.ProposedGoal.StatusUpdatedTick != 0 || + proposal.ProposedGoal.StatusSourceEventID != "" { + return &ValidationError{Field: field + ".proposed_goal", Message: "must match an active zero-progress goal_id without state metadata"} } found = true } diff --git a/protocol/types.go b/protocol/types.go index eb63c93..f3545f6 100644 --- a/protocol/types.go +++ b/protocol/types.go @@ -28,6 +28,21 @@ type Goal struct { Progress int `json:"progress"` TargetProgress int `json:"target_progress"` Status string `json:"status"` + // UpdatedTick is the latest game occurrence tick whose status or progress + // has been merged into this goal. It prevents a late outcome from + // overwriting a newer terminal status. + UpdatedTick int64 `json:"updated_tick,omitempty"` + // ProgressAccumulator preserves the unclamped sum of authoritative + // progress deltas. Progress is its bounded projection, so late positive and + // negative deltas produce the same value regardless of report order. + ProgressAccumulator int64 `json:"progress_accumulator,omitempty"` + // StatusExplicit distinguishes a game-supplied status from the automatic + // active/completed projection of progress. + StatusExplicit bool `json:"status_explicit,omitempty"` + // StatusUpdatedTick and StatusSourceEventID order explicit game status + // updates independently from progress-only updates. + StatusUpdatedTick int64 `json:"status_updated_tick,omitempty"` + StatusSourceEventID string `json:"status_source_event_id,omitempty"` } type ActorSeed struct { @@ -49,6 +64,9 @@ type Fact struct { Visibility []string `json:"visibility,omitempty"` Confidence int `json:"confidence"` SourceEventID string `json:"source_event_id,omitempty"` + // ObservedTick records when the fact occurred in the authoritative game, + // rather than when its report reached Rin. + ObservedTick int64 `json:"observed_tick,omitempty"` } type Memory struct { @@ -125,6 +143,11 @@ type ActionProposal struct { GoalID string `json:"goal_id,omitempty"` ProposedGoal *Goal `json:"proposed_goal,omitempty"` Status string `json:"status"` + // OutcomeEventID and OutcomeTick are populated when the authoritative game + // reports this proposal's result. They also make rejected outcome event IDs + // discoverable and order accepted actions by occurrence rather than arrival. + OutcomeEventID string `json:"outcome_event_id,omitempty"` + OutcomeTick int64 `json:"outcome_tick,omitempty"` } type ActorState struct { @@ -139,9 +162,10 @@ type ActorState struct { } type RequestReceipt struct { - Kind string `json:"kind"` - EntityID string `json:"entity_id,omitempty"` - Revision uint64 `json:"revision"` + Kind string `json:"kind"` + EntityID string `json:"entity_id,omitempty"` + Revision uint64 `json:"revision"` + RequestHash string `json:"request_hash,omitempty"` } type SessionState struct { @@ -205,6 +229,8 @@ type GoalUpdate struct { Status string `json:"status,omitempty"` } +// CommitRequest reports the authoritative result after the game has applied or +// rejected a proposal. It does not authorize or execute the proposed action. type CommitRequest struct { ProtocolVersion string `json:"protocol_version"` SessionID string `json:"session_id"` diff --git a/protocol/validate.go b/protocol/validate.go index d7443bf..3763e8d 100644 --- a/protocol/validate.go +++ b/protocol/validate.go @@ -115,6 +115,17 @@ func validateGoal(field string, goal Goal) error { if goal.Progress < 0 || goal.Progress > goal.TargetProgress { return &ValidationError{Field: field + ".progress", Message: "must be between 0 and target_progress"} } + if goal.UpdatedTick < 0 { + return &ValidationError{Field: field + ".updated_tick", Message: "must not be negative"} + } + if goal.StatusUpdatedTick < 0 { + return &ValidationError{Field: field + ".status_updated_tick", Message: "must not be negative"} + } + if goal.StatusSourceEventID != "" { + if err := validateID(field+".status_source_event_id", goal.StatusSourceEventID); err != nil { + return err + } + } if goal.Status != "active" && goal.Status != "completed" && goal.Status != "released" { return &ValidationError{Field: field + ".status", Message: "must be active, completed, or released"} } @@ -201,6 +212,18 @@ func ValidateCreateSession(request CreateSessionRequest) error { if err := validateActor(fmt.Sprintf("actors[%d]", index), actor); err != nil { return err } + for goalIndex, goal := range actor.Goals { + if goal.UpdatedTick != 0 || + goal.ProgressAccumulator != 0 || + goal.StatusExplicit || + goal.StatusUpdatedTick != 0 || + goal.StatusSourceEventID != "" { + return &ValidationError{ + Field: fmt.Sprintf("actors[%d].goals[%d]", index, goalIndex), + Message: "server-owned occurrence metadata must be zero when creating a session", + } + } + } if _, exists := seen[actor.ID]; exists { return &ValidationError{Field: "actors", Message: "actor ids must be unique"} } @@ -225,6 +248,9 @@ func validateFact(field string, fact Fact) error { if fact.Confidence < 0 || fact.Confidence > 100 { return &ValidationError{Field: field + ".confidence", Message: "must be between 0 and 100"} } + if fact.ObservedTick < 0 { + return &ValidationError{Field: field + ".observed_tick", Message: "must not be negative"} + } if fact.SourceEventID != "" { if err := validateID(field+".source_event_id", fact.SourceEventID); err != nil { return err @@ -233,6 +259,19 @@ func validateFact(field string, fact Fact) error { return nil } +func validateRequestFact(field string, fact Fact) error { + if err := validateFact(field, fact); err != nil { + return err + } + if fact.ObservedTick != 0 { + return &ValidationError{ + Field: field + ".observed_tick", + Message: "is server-owned and must be zero in requests", + } + } + return nil +} + func ValidateObserve(request ObserveRequest) error { if err := validateVersion(request.ProtocolVersion); err != nil { return err @@ -267,7 +306,7 @@ func ValidateObserve(request ObserveRequest) error { return &ValidationError{Field: "facts", Message: "must contain at most 64 values"} } for index, fact := range request.Facts { - if err := validateFact(fmt.Sprintf("facts[%d]", index), fact); err != nil { + if err := validateRequestFact(fmt.Sprintf("facts[%d]", index), fact); err != nil { return err } } @@ -341,8 +380,14 @@ func ValidatePropose(request ProposeRequest) error { if err := validateGoal(field, goal); err != nil { return err } - if goal.Progress != 0 || goal.Status != "active" { - return &ValidationError{Field: field, Message: "candidate goals must be active with zero progress"} + if goal.Progress != 0 || + goal.Status != "active" || + goal.UpdatedTick != 0 || + goal.ProgressAccumulator != 0 || + goal.StatusExplicit || + goal.StatusUpdatedTick != 0 || + goal.StatusSourceEventID != "" { + return &ValidationError{Field: field, Message: "candidate goals must be active with zero progress and no state metadata"} } if _, exists := goalIDs[goal.ID]; exists { return &ValidationError{Field: "candidate_goals", Message: "goal ids must be unique"} @@ -374,7 +419,7 @@ func ValidateCommit(request CommitRequest) error { return &ValidationError{Field: "commit", Message: "contains too many updates"} } for index, fact := range request.Facts { - if err := validateFact(fmt.Sprintf("facts[%d]", index), fact); err != nil { + if err := validateRequestFact(fmt.Sprintf("facts[%d]", index), fact); err != nil { return err } } diff --git a/protocol/validate_test.go b/protocol/validate_test.go index bb4fac1..97f235b 100644 --- a/protocol/validate_test.go +++ b/protocol/validate_test.go @@ -48,6 +48,113 @@ func TestCreateValidationNegotiatesKnownFeatures(t *testing.T) { } } +func TestOccurrenceMetadataIsServerOwnedAndNonNegative(t *testing.T) { + create := validCreate() + create.Actors[0].Goals[0].UpdatedTick = 1 + if err := protocol.ValidateCreateSession(create); err == nil { + t.Fatal("create request supplied server-owned goal updated_tick") + } + create = validCreate() + create.Actors[0].Goals[0].ProgressAccumulator = 1 + if err := protocol.ValidateCreateSession(create); err == nil { + t.Fatal("create request supplied server-owned progress_accumulator") + } + create = validCreate() + create.Actors[0].Goals[0].StatusExplicit = true + if err := protocol.ValidateCreateSession(create); err == nil { + t.Fatal("create request supplied server-owned status_explicit") + } + + proposal := protocol.ProposeRequest{ + ProtocolVersion: protocol.Version, + SessionID: "session.test", + RequestID: "proposal.metadata", + ActorID: "npc.test", + Intent: "choose", + CandidateActions: []protocol.ActionSpec{{ + ID: "wait", Kind: "wait", Description: "wait", + }}, + CandidateGoals: []protocol.Goal{{ + ID: "goal.new", Description: "A bounded goal", Priority: 3, + TargetProgress: 2, Status: "active", UpdatedTick: 1, + }}, + } + if err := protocol.ValidatePropose(proposal); err == nil { + t.Fatal("candidate goal supplied server-owned updated_tick") + } + + commit := protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: "session.test", + RequestID: "commit.metadata", + ProposalID: "proposal.test", + EventID: "event.test", + Accepted: true, + Outcome: "Applied.", + Facts: []protocol.Fact{{ + SubjectID: "door", Predicate: "state", Object: "open", + Confidence: 100, ObservedTick: -1, + }}, + } + if err := protocol.ValidateCommit(commit); err == nil { + t.Fatal("negative fact observed_tick should fail") + } + + serverStampedFact := protocol.Fact{ + SubjectID: "door", Predicate: "state", Object: "open", + Confidence: 100, ObservedTick: 7, + } + requests := map[string]func() error{ + "observe": func() error { + return protocol.ValidateObserve(protocol.ObserveRequest{ + ProtocolVersion: protocol.Version, + SessionID: "session.test", + RequestID: "observe.metadata", + EventID: "event.metadata", + ObserverIDs: []string{"npc.test"}, + Source: "game", + Kind: "world", + Summary: "The door opened.", + Importance: 1, + Facts: []protocol.Fact{serverStampedFact}, + }) + }, + "commit": func() error { + return protocol.ValidateCommit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: "session.test", + RequestID: "commit.metadata-positive", + ProposalID: "proposal.test", + EventID: "event.metadata-positive", + Accepted: true, + Outcome: "Applied.", + Facts: []protocol.Fact{serverStampedFact}, + }) + }, + "batch": func() error { + return protocol.ValidateBatchCommit(protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: "session.test", + RequestID: "batch.metadata", + Items: []protocol.CommitItem{{ + ProposalID: "proposal.test", + EventID: "event.metadata-batch", + Accepted: true, + Outcome: "Applied.", + Facts: []protocol.Fact{serverStampedFact}, + }}, + }) + }, + } + for name, validate := range requests { + t.Run(name, func(t *testing.T) { + if err := validate(); err == nil { + t.Fatal("request supplied server-owned positive observed_tick") + } + }) + } +} + func TestProposalRequiresUniqueWhitelistedShape(t *testing.T) { request := protocol.ProposeRequest{ ProtocolVersion: protocol.Version, diff --git a/runtime/atomic_append_test.go b/runtime/atomic_append_test.go new file mode 100644 index 0000000..caa375b --- /dev/null +++ b/runtime/atomic_append_test.go @@ -0,0 +1,1208 @@ +package runtime_test + +import ( + "context" + "errors" + "reflect" + "sync" + "testing" + + "github.com/sunrioa/rin/policy" + "github.com/sunrioa/rin/protocol" + rinruntime "github.com/sunrioa/rin/runtime" + "github.com/sunrioa/rin/store" +) + +func TestCommitAppendFailureDoesNotMutateLiveStateAndRetryReplays(t *testing.T) { + eventStore := newFailOnceAppendStore(store.NewMemory()) + engine := newEngine(t, eventStore, policy.Deterministic{}) + const sessionID = "session.atomic-commit" + + if _, err := engine.CreateSession(createRequest(sessionID)); err != nil { + t.Fatal(err) + } + proposal, _, err := engine.Propose(context.Background(), proposeRequest(sessionID, "propose.atomic-commit", 0, nil)) + if err != nil { + t.Fatal(err) + } + before, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + request := protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.atomic-commit", + ProposalID: proposal.ID, + EventID: "event.atomic-commit", + Tick: proposal.Tick, + Accepted: true, + Outcome: "The game applied the action before reporting it.", + } + + eventStore.failNextAppend() + if _, err := engine.Commit(request); !errors.Is(err, errInjectedAppend) || rinruntime.ErrorCode(err) != "store_append_failed" { + t.Fatalf("expected injected store_append_failed, got %v", err) + } + afterFailure, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, afterFailure) { + t.Fatalf("failed append mutated live state:\nbefore=%+v\nafter=%+v", before, afterFailure) + } + + result, err := engine.Commit(request) + if err != nil { + t.Fatalf("same request id should retry after an unpersisted failure: %v", err) + } + if result.Duplicate { + t.Fatalf("retry of an unpersisted request was incorrectly reported as duplicate: %+v", result) + } + assertAcceptedOutcomeOnce(t, engine, sessionID, proposal.ActorID, proposal.ID, request.EventID) + + repeated, err := engine.Commit(request) + if err != nil { + t.Fatalf("persisted request should be idempotent: %v", err) + } + if !repeated.Duplicate || repeated.Revision != result.Revision { + t.Fatalf("persisted retry should return the original revision as duplicate: first=%+v repeated=%+v", result, repeated) + } + + reopened := newEngine(t, eventStore, policy.Deterministic{}) + assertAcceptedOutcomeOnce(t, reopened, sessionID, proposal.ActorID, proposal.ID, request.EventID) + replayed, err := reopened.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if replayed.Revision != result.Revision { + t.Fatalf("replay revision = %d, want %d", replayed.Revision, result.Revision) + } +} + +func TestBatchCommitAppendFailureDoesNotMutateLiveStateAndRetryReplays(t *testing.T) { + eventStore := newFailOnceAppendStore(store.NewMemory()) + engine := newEngine(t, eventStore, policy.Deterministic{}) + create := twoActorWorldRequest("session.atomic-batch") + + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + mira, _, err := engine.Propose(context.Background(), targetedProposalRequest(create.SessionID, "propose.atomic-mira", "npc.mira")) + if err != nil { + t.Fatal(err) + } + oren, _, err := engine.Propose(context.Background(), targetedProposalRequest(create.SessionID, "propose.atomic-oren", "npc.oren")) + if err != nil { + t.Fatal(err) + } + before, err := engine.State(sessionRequest(create.SessionID)) + if err != nil { + t.Fatal(err) + } + request := protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: create.SessionID, + RequestID: "commit.atomic-batch", + Tick: mira.Tick, + Items: []protocol.CommitItem{ + { + ProposalID: mira.ID, + EventID: "event.atomic-mira", + Accepted: true, + Outcome: "Mira completed the coordinated action.", + }, + { + ProposalID: oren.ID, + EventID: "event.atomic-oren", + Accepted: true, + Outcome: "Oren completed the coordinated action.", + }, + }, + } + + eventStore.failNextAppend() + if _, err := engine.CommitBatch(request); !errors.Is(err, errInjectedAppend) || rinruntime.ErrorCode(err) != "store_append_failed" { + t.Fatalf("expected injected store_append_failed, got %v", err) + } + afterFailure, err := engine.State(sessionRequest(create.SessionID)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, afterFailure) { + t.Fatalf("failed batch append mutated live state:\nbefore=%+v\nafter=%+v", before, afterFailure) + } + + result, err := engine.CommitBatch(request) + if err != nil { + t.Fatalf("same batch request id should retry after an unpersisted failure: %v", err) + } + if result.Duplicate { + t.Fatalf("retry of an unpersisted batch was incorrectly reported as duplicate: %+v", result) + } + assertAcceptedOutcomeOnce(t, engine, create.SessionID, mira.ActorID, mira.ID, request.Items[0].EventID) + assertAcceptedOutcomeOnce(t, engine, create.SessionID, oren.ActorID, oren.ID, request.Items[1].EventID) + + repeated, err := engine.CommitBatch(request) + if err != nil { + t.Fatalf("persisted batch should be idempotent: %v", err) + } + if !repeated.Duplicate || repeated.Revision != result.Revision { + t.Fatalf("persisted batch retry should return the original revision as duplicate: first=%+v repeated=%+v", result, repeated) + } + + reopened := newEngine(t, eventStore, policy.Deterministic{}) + assertAcceptedOutcomeOnce(t, reopened, create.SessionID, mira.ActorID, mira.ID, request.Items[0].EventID) + assertAcceptedOutcomeOnce(t, reopened, create.SessionID, oren.ActorID, oren.ID, request.Items[1].EventID) + replayed, err := reopened.State(sessionRequest(create.SessionID)) + if err != nil { + t.Fatal(err) + } + if replayed.Revision != result.Revision { + t.Fatalf("batch replay revision = %d, want %d", replayed.Revision, result.Revision) + } +} + +func TestCommitReconcilesPostWriteAppendErrorWithoutDuplicateLogEntry(t *testing.T) { + eventStore := newFailAfterAppendOnceStore(store.NewMemory()) + engine := newEngine(t, eventStore, policy.Deterministic{}) + const sessionID = "session.atomic-post-write" + if _, err := engine.CreateSession(createRequest(sessionID)); err != nil { + t.Fatal(err) + } + proposal, _, err := engine.Propose( + context.Background(), + proposeRequest(sessionID, "propose.atomic-post-write", 0, nil), + ) + if err != nil { + t.Fatal(err) + } + request := protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.atomic-post-write", + ProposalID: proposal.ID, + EventID: "event.atomic-post-write", + Tick: 0, + Accepted: true, + Outcome: "The game already applied this action.", + } + + eventStore.failAfterNextAppend() + result, err := engine.Commit(request) + if err != nil { + t.Fatalf("engine should reconcile an exact event written before an append error: %v", err) + } + if result.Duplicate { + t.Fatalf("first reconciled report is not a client duplicate: %+v", result) + } + if calls := eventStore.appendCallCount(); calls != 2 { + t.Fatalf("post-write reconciliation used %d append calls, want initial append plus exact retry", calls) + } + events, err := eventStore.Load(sessionID) + if err != nil { + t.Fatal(err) + } + if len(events) != 3 { + t.Fatalf("post-write reconciliation appended %d log events, want 3", len(events)) + } + assertAcceptedOutcomeOnce(t, engine, sessionID, proposal.ActorID, proposal.ID, request.EventID) + + reopened := newEngine(t, eventStore, policy.Deterministic{}) + assertAcceptedOutcomeOnce(t, reopened, sessionID, proposal.ActorID, proposal.ID, request.EventID) +} + +func TestAppendReconciliationNeverPublishesUnverifiedLoadedEvent(t *testing.T) { + for _, test := range staleHashEventMutations() { + t.Run(test.name, func(t *testing.T) { + delegate := store.NewMemory() + eventStore := &tamperedConfirmationStore{Store: delegate} + engine := newEngine(t, eventStore, policy.Deterministic{}) + sessionID := "session.atomic-append-tamper-" + test.name + if _, err := engine.CreateSession(createRequest(sessionID)); err != nil { + t.Fatal(err) + } + before, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + eventStore.failAfterNextAppend(test.mutate) + + request := observeRequest( + sessionID, + "observe.atomic-append-tamper-"+test.name, + "event.atomic-append-tamper-"+test.name, + 1, + ) + if _, err := engine.Observe(request); err == nil { + t.Fatalf("unverified loaded tail should fail reconciliation: %v", err) + } + after, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(after, before) { + t.Fatalf("unverified loaded tail advanced live state:\nbefore=%+v\nafter=%+v", before, after) + } + events, err := delegate.Load(sessionID) + if err != nil { + t.Fatal(err) + } + if len(events) != 2 { + t.Fatalf("post-write store contains %d events, want 2", len(events)) + } + }) + } +} + +func TestCommitRecoversWhenPostWriteConfirmationInitiallyFails(t *testing.T) { + eventStore := newFailAfterAppendAndConfirmationStore(store.NewMemory()) + engine := newEngine(t, eventStore, policy.Deterministic{}) + const sessionID = "session.atomic-confirmation" + if _, err := engine.CreateSession(createRequest(sessionID)); err != nil { + t.Fatal(err) + } + proposal, _, err := engine.Propose( + context.Background(), + proposeRequest(sessionID, "propose.atomic-confirmation", 0, nil), + ) + if err != nil { + t.Fatal(err) + } + before, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + request := protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.atomic-confirmation", + ProposalID: proposal.ID, + EventID: "event.atomic-confirmation", + Tick: 0, + Accepted: true, + Outcome: "The game already applied this action.", + } + + eventStore.failPostWriteAndConfirmation() + if _, err := engine.Commit(request); !errors.Is(err, errInjectedAppend) || + rinruntime.ErrorCode(err) != "store_append_failed" { + t.Fatalf("failed durability confirmation should be reported: %v", err) + } + afterFailure, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, afterFailure) { + t.Fatalf("unconfirmed append advanced live state:\nbefore=%+v\nafter=%+v", before, afterFailure) + } + if calls := eventStore.appendCallCount(); calls != 2 { + t.Fatalf("failed confirmation used %d append calls, want 2", calls) + } + + altered := request + altered.Outcome = "A different payload must not claim the persisted request." + eventStore.forceNextAppendConflict() + if _, err := engine.Commit(altered); err == nil || + rinruntime.ErrorCode(err) != "store_append_failed" { + t.Fatalf("altered same-ID retry must not reconcile the persisted event: %v", err) + } + afterAltered, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, afterAltered) { + t.Fatalf("altered retry advanced live state:\nbefore=%+v\nafter=%+v", before, afterAltered) + } + events, err := eventStore.Load(sessionID) + if err != nil { + t.Fatal(err) + } + if len(events) != 3 { + t.Fatalf("altered retry changed persisted event count to %d, want 3", len(events)) + } + + eventStore.forceNextAppendConflict() + if _, err := engine.Commit(request); err != nil { + t.Fatalf("client retry should reconcile the previously persisted logical event: %v", err) + } + if calls := eventStore.appendCallCount(); calls != 5 { + t.Fatalf("logical reconciliation used %d append calls, want 5", calls) + } + assertAcceptedOutcomeOnce(t, engine, sessionID, proposal.ActorID, proposal.ID, request.EventID) + events, err = eventStore.Load(sessionID) + if err != nil { + t.Fatal(err) + } + if len(events) != 3 { + t.Fatalf("confirmation recovery left %d events, want 3", len(events)) + } + reopened := newEngine(t, eventStore, policy.Deterministic{}) + assertAcceptedOutcomeOnce(t, reopened, sessionID, proposal.ActorID, proposal.ID, request.EventID) +} + +func TestProposeReportsUnknownAndSameRequestRecoversAfterConfirmationFailure(t *testing.T) { + eventStore := newFailAfterAppendAndConfirmationStore(store.NewMemory()) + engine := newEngine(t, eventStore, policy.Deterministic{}) + const sessionID = "session.atomic-proposal-confirmation" + if _, err := engine.CreateSession(createRequest(sessionID)); err != nil { + t.Fatal(err) + } + before, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + request := proposeRequest(sessionID, "propose.atomic-confirmation", 0, nil) + + eventStore.failPostWriteAndConfirmation() + if _, _, err := engine.Propose(context.Background(), request); !errors.Is(err, errInjectedAppend) || + rinruntime.ErrorCode(err) != "proposal_outcome_unknown" { + t.Fatalf("failed Proposal durability confirmation should report proposal_outcome_unknown: %v", err) + } + afterFailure, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, afterFailure) { + t.Fatalf("unconfirmed Proposal append advanced live state:\nbefore=%+v\nafter=%+v", before, afterFailure) + } + if calls := eventStore.appendCallCount(); calls != 2 { + t.Fatalf("failed Proposal confirmation used %d append calls, want 2", calls) + } + events, err := eventStore.Load(sessionID) + if err != nil { + t.Fatal(err) + } + if len(events) != 2 || events[1].Type != rinruntime.EventProposed { + t.Fatalf("Proposal outcome should be uncertain because its event is already present: %+v", events) + } + + proposal, duplicate, err := engine.Propose(context.Background(), request) + if err != nil { + t.Fatalf("same request should reconcile the previously persisted Proposal: %v", err) + } + if duplicate { + t.Fatalf("the first confirmed response should not be marked as a client duplicate: %+v", proposal) + } + if calls := eventStore.appendCallCount(); calls != 3 { + t.Fatalf("Proposal recovery used %d append calls, want one exact same-event confirmation", calls) + } + state, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if receipt := state.Receipts[request.RequestID]; receipt.Kind != rinruntime.EventProposed || + receipt.EntityID != proposal.ID { + t.Fatalf("recovered Proposal receipt mismatch: %+v proposal=%+v", receipt, proposal) + } + if retained := state.Proposals[proposal.ID]; !reflect.DeepEqual(retained, proposal) { + t.Fatalf("recovered Proposal mismatch:\nretained=%+v\nreturned=%+v", retained, proposal) + } + events, err = eventStore.Load(sessionID) + if err != nil { + t.Fatal(err) + } + if len(events) != 2 { + t.Fatalf("Proposal recovery left %d events, want exactly 2", len(events)) + } +} + +func TestProposalReconciliationFailureIsOutcomeUnknownAndRetryable(t *testing.T) { + eventStore := newCorruptProposalReconcileOnceStore(store.NewMemory()) + changingPolicy := &changingAtomicPolicy{} + engine := newEngine(t, eventStore, changingPolicy) + const sessionID = "session.atomic-proposal-reconcile" + if _, err := engine.CreateSession(createRequest(sessionID)); err != nil { + t.Fatal(err) + } + request := proposeRequest(sessionID, "propose.atomic-reconcile", 0, nil) + + eventStore.failAfterWriteAndCorruptLoad() + if _, _, err := engine.Propose(context.Background(), request); err == nil || + rinruntime.ErrorCode(err) != "proposal_outcome_unknown" { + t.Fatalf("persisted but unreconciled Proposal must be outcome-unknown: %v", err) + } + state, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if len(state.Proposals) != 0 || state.Revision != 1 { + t.Fatalf("unreconciled Proposal advanced live state: %+v", state) + } + altered := request + altered.Intent = "A different request must not claim the uncertain event." + if _, _, err := engine.Propose(context.Background(), altered); err == nil || + rinruntime.ErrorCode(err) != "request_id_conflict" { + t.Fatalf("altered retry claimed an uncertain Proposal identity: %v", err) + } + + proposal, duplicate, err := engine.Propose(context.Background(), request) + if err != nil { + t.Fatalf("same request did not reconcile the persisted Proposal: %v", err) + } + if duplicate || proposal.RequestID != request.RequestID { + t.Fatalf("unexpected reconciled Proposal: %+v duplicate=%v", proposal, duplicate) + } + if proposal.Action.ID != "talk" || changingPolicy.callCount() != 1 { + t.Fatalf( + "retry reran the non-deterministic policy: proposal=%+v calls=%d", + proposal, + changingPolicy.callCount(), + ) + } + events, err := eventStore.Load(sessionID) + if err != nil { + t.Fatal(err) + } + if len(events) != 2 || events[1].Type != rinruntime.EventProposed { + t.Fatalf("reconciliation should retain one Proposal event: %+v", events) + } +} + +func TestUncertainProposalBlocksOtherMutationsUntilExactRetry(t *testing.T) { + eventStore := newFailProposalBeforeWriteAndLoadOnceStore(store.NewMemory()) + changingPolicy := &changingAtomicPolicy{} + engine := newEngine(t, eventStore, changingPolicy) + const sessionID = "session.atomic-proposal-block" + if _, err := engine.CreateSession(createRequest(sessionID)); err != nil { + t.Fatal(err) + } + request := proposeRequest(sessionID, "propose.atomic-block", 0, nil) + + eventStore.failProposalAndLoad() + if _, _, err := engine.Propose(context.Background(), request); err == nil || + rinruntime.ErrorCode(err) != "proposal_outcome_unknown" { + t.Fatalf("indeterminate Proposal append must report proposal_outcome_unknown: %v", err) + } + if calls := eventStore.appendCallCount(); calls != 1 { + t.Fatalf("failed Proposal used %d append calls, want 1", calls) + } + + observation := observeRequest(sessionID, "observe.while-proposal-unknown", "event.while-proposal-unknown", 0) + if _, err := engine.Observe(observation); err == nil || + rinruntime.ErrorCode(err) != "proposal_outcome_unknown" { + t.Fatalf("another mutation must be blocked behind the uncertain Proposal: %v", err) + } + if calls := eventStore.appendCallCount(); calls != 1 { + t.Fatalf("blocked observation reached the store; append calls = %d, want 1", calls) + } + events, err := eventStore.Load(sessionID) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 { + t.Fatalf("blocked mutation changed persisted event count to %d, want 1", len(events)) + } + + proposal, duplicate, err := engine.Propose(context.Background(), request) + if err != nil { + t.Fatalf("exact Proposal retry did not recover the session: %v", err) + } + if duplicate || proposal.RequestID != request.RequestID { + t.Fatalf("unexpected recovered Proposal: %+v duplicate=%v", proposal, duplicate) + } + if changingPolicy.callCount() != 1 { + t.Fatalf("exact retry reran policy %d times, want once", changingPolicy.callCount()) + } + if calls := eventStore.appendCallCount(); calls != 2 { + t.Fatalf("exact recovery used %d append calls, want 2", calls) + } + if _, err := engine.Observe(observation); err != nil { + t.Fatalf("mutations should resume after exact Proposal recovery: %v", err) + } +} + +func TestProposalRequestHashRejectsAlteredRetriesAfterReplay(t *testing.T) { + eventStore := store.NewMemory() + engine := newEngine(t, eventStore, policy.Deterministic{}) + const sessionID = "session.proposal-request-hash" + if _, err := engine.CreateSession(createRequest(sessionID)); err != nil { + t.Fatal(err) + } + request := proposeRequest(sessionID, "propose.request-hash", 0, nil) + proposal, duplicate, err := engine.Propose(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if duplicate { + t.Fatal("first Proposal was reported as a duplicate") + } + state, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if receipt := state.Receipts[request.RequestID]; len(receipt.RequestHash) != 64 { + t.Fatalf("Proposal receipt did not retain a SHA-256 request hash: %+v", receipt) + } + invalidState := state + invalidState.Receipts = make(map[string]protocol.RequestReceipt, len(state.Receipts)) + for requestID, receipt := range state.Receipts { + invalidState.Receipts[requestID] = receipt + } + invalidReceipt := invalidState.Receipts[request.RequestID] + invalidReceipt.RequestHash = "NOT-A-SHA256-DIGEST" + invalidState.Receipts[request.RequestID] = invalidReceipt + if err := protocol.ValidateSessionState(invalidState); err == nil { + t.Fatal("session validation accepted an invalid Proposal request hash") + } + + repeated, duplicate, err := engine.Propose(context.Background(), request) + if err != nil || !duplicate || !reflect.DeepEqual(repeated, proposal) { + t.Fatalf("exact Proposal retry was not idempotent: proposal=%+v duplicate=%v err=%v", repeated, duplicate, err) + } + altered := request + altered.Intent = "A different payload must not reuse the persisted request id." + if _, _, err := engine.Propose(context.Background(), altered); err == nil || + rinruntime.ErrorCode(err) != "request_id_conflict" { + t.Fatalf("altered live retry did not conflict: %v", err) + } + + reopened := newEngine(t, eventStore, policy.Deterministic{}) + if _, _, err := reopened.Propose(context.Background(), altered); err == nil || + rinruntime.ErrorCode(err) != "request_id_conflict" { + t.Fatalf("altered replayed retry did not conflict: %v", err) + } + replayed, duplicate, err := reopened.Propose(context.Background(), request) + if err != nil || !duplicate || !reflect.DeepEqual(replayed, proposal) { + t.Fatalf("exact replayed retry was not idempotent: proposal=%+v duplicate=%v err=%v", replayed, duplicate, err) + } +} + +func TestCreateReconcilesPostWriteErrorWithoutRestart(t *testing.T) { + eventStore := newAmbiguousCreateStore(store.NewMemory()) + eventStore.failAfterWrite(false) + engine := newEngine(t, eventStore, policy.Deterministic{}) + request := createRequest("session.atomic-create") + + result, err := engine.CreateSession(request) + if err != nil { + t.Fatalf("engine should reconcile a fully written create event: %v", err) + } + if result.Duplicate || result.Revision != 1 { + t.Fatalf("unexpected reconciled create result: %+v", result) + } + if calls := eventStore.createCallCount(); calls != 2 { + t.Fatalf("create reconciliation used %d calls, want write plus exact confirmation", calls) + } + events, err := eventStore.Load(request.SessionID) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 { + t.Fatalf("create reconciliation persisted %d events, want 1", len(events)) + } + repeated, err := engine.CreateSession(request) + if err != nil { + t.Fatalf("registered create retry should be idempotent: %v", err) + } + if !repeated.Duplicate || repeated.Revision != result.Revision { + t.Fatalf("registered create retry mismatch: first=%+v repeated=%+v", result, repeated) + } + reopened := newEngine(t, eventStore, policy.Deterministic{}) + if _, err := reopened.State(sessionRequest(request.SessionID)); err != nil { + t.Fatalf("reconciled create did not replay after restart: %v", err) + } +} + +func TestCreateReconciliationNeverPublishesUnverifiedLoadedEvent(t *testing.T) { + for _, test := range staleHashEventMutations() { + t.Run(test.name, func(t *testing.T) { + delegate := store.NewMemory() + eventStore := &tamperedConfirmationStore{Store: delegate} + eventStore.failAfterNextCreate(test.mutate) + engine := newEngine(t, eventStore, policy.Deterministic{}) + request := createRequest("session.atomic-create-tamper-" + test.name) + + if _, err := engine.CreateSession(request); err == nil { + t.Fatalf("unverified loaded create should fail reconciliation: %v", err) + } + if _, err := engine.State(sessionRequest(request.SessionID)); rinruntime.ErrorCode(err) != "session_not_found" { + t.Fatalf("unverified loaded create registered live state: %v", err) + } + events, err := delegate.Load(request.SessionID) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 { + t.Fatalf("post-write store contains %d events, want 1", len(events)) + } + }) + } +} + +func TestCreateRetryRecoversAfterConfirmationFailure(t *testing.T) { + eventStore := newAmbiguousCreateStore(store.NewMemory()) + eventStore.failAfterWrite(true) + engine := newEngine(t, eventStore, policy.Deterministic{}) + request := createRequest("session.atomic-create-retry") + + if _, err := engine.CreateSession(request); !errors.Is(err, errInjectedAppend) || + rinruntime.ErrorCode(err) != "store_create_failed" { + t.Fatalf("failed create confirmation should be reported: %v", err) + } + if _, err := engine.State(sessionRequest(request.SessionID)); rinruntime.ErrorCode(err) != "session_not_found" { + t.Fatalf("unconfirmed create must not register live state: %v", err) + } + result, err := engine.CreateSession(request) + if err != nil { + t.Fatalf("same-engine retry should reconcile persisted create: %v", err) + } + if result.Duplicate { + t.Fatalf("first registered result is not a client duplicate: %+v", result) + } + if calls := eventStore.createCallCount(); calls != 4 { + t.Fatalf("create recovery used %d calls, want failed write/confirm plus logical/exact retry", calls) + } + events, err := eventStore.Load(request.SessionID) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 { + t.Fatalf("create recovery persisted %d events, want 1", len(events)) + } + reopened := newEngine(t, eventStore, policy.Deterministic{}) + if _, err := reopened.State(sessionRequest(request.SessionID)); err != nil { + t.Fatalf("recovered create did not replay after restart: %v", err) + } +} + +func TestFreshRestoreRetryRecoversAfterConfirmationFailure(t *testing.T) { + const sessionID = "session.atomic-fresh-restore" + source := newEngine(t, store.NewMemory(), policy.Deterministic{}) + if _, err := source.CreateSession(createRequest(sessionID)); err != nil { + t.Fatal(err) + } + snapshot, err := source.Snapshot(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + + eventStore := newAmbiguousCreateStore(store.NewMemory()) + eventStore.failAfterWrite(true) + engine := newEngine(t, eventStore, policy.Deterministic{}) + request := protocol.RestoreRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "restore.atomic-fresh", + Snapshot: snapshot, + } + if _, err := engine.Restore(request); !errors.Is(err, errInjectedAppend) || + rinruntime.ErrorCode(err) != "store_create_failed" { + t.Fatalf("failed fresh-restore confirmation should be reported: %v", err) + } + if _, err := engine.State(sessionRequest(sessionID)); rinruntime.ErrorCode(err) != "session_not_found" { + t.Fatalf("unconfirmed fresh restore must not register live state: %v", err) + } + result, err := engine.Restore(request) + if err != nil { + t.Fatalf("same-engine restore retry should reconcile persisted event: %v", err) + } + if result.Duplicate { + t.Fatalf("first registered restore result is not a client duplicate: %+v", result) + } + if calls := eventStore.createCallCount(); calls != 4 { + t.Fatalf("fresh-restore recovery used %d create calls, want 4", calls) + } + state, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if receipt := state.Receipts[request.RequestID]; receipt.Kind != rinruntime.EventSessionRestored { + t.Fatalf("fresh restore receipt was not reconciled: %+v", receipt) + } + events, err := eventStore.Load(sessionID) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 { + t.Fatalf("fresh restore recovery persisted %d events, want 1", len(events)) + } + reopened := newEngine(t, eventStore, policy.Deterministic{}) + if _, err := reopened.State(sessionRequest(sessionID)); err != nil { + t.Fatalf("recovered fresh restore did not replay after restart: %v", err) + } +} + +func assertAcceptedOutcomeOnce( + t *testing.T, + engine *rinruntime.Engine, + sessionID string, + actorID string, + proposalID string, + eventID string, +) { + t.Helper() + state, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if proposal := state.Proposals[proposalID]; proposal.Status != "accepted" { + t.Fatalf("proposal %q status = %q, want accepted", proposalID, proposal.Status) + } + actor := state.Actors[actorID] + memoryCount := 0 + for _, memory := range actor.Memories { + if memory.EventID == eventID { + memoryCount++ + } + } + if memoryCount != 1 { + t.Fatalf("event %q appears in actor memory %d times, want exactly once", eventID, memoryCount) + } + recentCount := 0 + for _, action := range actor.RecentActions { + if action.ID == proposalID { + recentCount++ + } + } + if recentCount != 1 { + t.Fatalf("proposal %q appears in recent actions %d times, want exactly once", proposalID, recentCount) + } +} + +var errInjectedAppend = errors.New("injected append failure") + +type failOnceAppendStore struct { + rinruntime.Store + + mu sync.Mutex + failNext bool +} + +type failAfterAppendOnceStore struct { + rinruntime.Store + + mu sync.Mutex + failNext bool + appendCalls int +} + +type failAfterAppendAndConfirmationStore struct { + rinruntime.Store + + mu sync.Mutex + failStage int + appendCalls int + forceConflict bool +} + +type corruptProposalReconcileOnceStore struct { + rinruntime.Store + + mu sync.Mutex + failNext bool + corruptNext bool +} + +type failProposalBeforeWriteAndLoadOnceStore struct { + rinruntime.Store + + mu sync.Mutex + failAppend bool + failLoad bool + appendCalls int +} + +type changingAtomicPolicy struct { + mu sync.Mutex + calls int +} + +type ambiguousCreateStore struct { + rinruntime.Store + + mu sync.Mutex + failStage int + failConfirmationOnce bool + createCalls int +} + +type tamperedConfirmationStore struct { + rinruntime.Store + + mu sync.Mutex + failCreate func(*protocol.EventRecord) + failAppend func(*protocol.EventRecord) + tamperNextLoad func(*protocol.EventRecord) +} + +func newFailOnceAppendStore(delegate rinruntime.Store) *failOnceAppendStore { + return &failOnceAppendStore{Store: delegate} +} + +func newFailAfterAppendOnceStore(delegate rinruntime.Store) *failAfterAppendOnceStore { + return &failAfterAppendOnceStore{Store: delegate} +} + +func newFailAfterAppendAndConfirmationStore(delegate rinruntime.Store) *failAfterAppendAndConfirmationStore { + return &failAfterAppendAndConfirmationStore{Store: delegate} +} + +func newCorruptProposalReconcileOnceStore(delegate rinruntime.Store) *corruptProposalReconcileOnceStore { + return &corruptProposalReconcileOnceStore{Store: delegate} +} + +func newFailProposalBeforeWriteAndLoadOnceStore( + delegate rinruntime.Store, +) *failProposalBeforeWriteAndLoadOnceStore { + return &failProposalBeforeWriteAndLoadOnceStore{Store: delegate} +} + +func newAmbiguousCreateStore(delegate rinruntime.Store) *ambiguousCreateStore { + return &ambiguousCreateStore{Store: delegate} +} + +func (s *ambiguousCreateStore) failAfterWrite(failConfirmation bool) { + s.mu.Lock() + s.failStage = 1 + s.failConfirmationOnce = failConfirmation + s.createCalls = 0 + s.mu.Unlock() +} + +func (s *ambiguousCreateStore) Create(sessionID string, event protocol.EventRecord) error { + s.mu.Lock() + s.createCalls++ + stage := s.failStage + switch stage { + case 1: + s.failStage = 2 + case 2: + s.failStage = 0 + } + failConfirmation := s.failConfirmationOnce + s.mu.Unlock() + + switch stage { + case 1: + if err := s.Store.Create(sessionID, event); err != nil { + return err + } + return errInjectedAppend + case 2: + if failConfirmation { + return errInjectedAppend + } + return s.Store.Create(sessionID, event) + default: + return s.Store.Create(sessionID, event) + } +} + +func (s *ambiguousCreateStore) createCallCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.createCalls +} + +func (s *tamperedConfirmationStore) failAfterNextCreate(mutate func(*protocol.EventRecord)) { + s.mu.Lock() + s.failCreate = mutate + s.mu.Unlock() +} + +func (s *tamperedConfirmationStore) failAfterNextAppend(mutate func(*protocol.EventRecord)) { + s.mu.Lock() + s.failAppend = mutate + s.mu.Unlock() +} + +func (s *tamperedConfirmationStore) Create(sessionID string, event protocol.EventRecord) error { + s.mu.Lock() + mutate := s.failCreate + s.failCreate = nil + s.mu.Unlock() + if mutate == nil { + return s.Store.Create(sessionID, event) + } + if err := s.Store.Create(sessionID, event); err != nil { + return err + } + s.mu.Lock() + s.tamperNextLoad = mutate + s.mu.Unlock() + return errInjectedAppend +} + +func (s *tamperedConfirmationStore) Append(sessionID string, event protocol.EventRecord) error { + s.mu.Lock() + mutate := s.failAppend + s.failAppend = nil + s.mu.Unlock() + if mutate == nil { + return s.Store.Append(sessionID, event) + } + if err := s.Store.Append(sessionID, event); err != nil { + return err + } + s.mu.Lock() + s.tamperNextLoad = mutate + s.mu.Unlock() + return errInjectedAppend +} + +func (s *tamperedConfirmationStore) Load(sessionID string) ([]protocol.EventRecord, error) { + events, err := s.Store.Load(sessionID) + if err != nil { + return nil, err + } + s.mu.Lock() + mutate := s.tamperNextLoad + s.tamperNextLoad = nil + s.mu.Unlock() + if mutate == nil || len(events) == 0 { + return events, nil + } + result := make([]protocol.EventRecord, len(events)) + for index, event := range events { + event.Data = append([]byte(nil), event.Data...) + result[index] = event + } + mutate(&result[len(result)-1]) + return result, nil +} + +func (s *failOnceAppendStore) failNextAppend() { + s.mu.Lock() + s.failNext = true + s.mu.Unlock() +} + +func (s *failOnceAppendStore) Append(sessionID string, event protocol.EventRecord) error { + s.mu.Lock() + if s.failNext { + s.failNext = false + s.mu.Unlock() + return errInjectedAppend + } + s.mu.Unlock() + return s.Store.Append(sessionID, event) +} + +func (s *failAfterAppendOnceStore) failAfterNextAppend() { + s.mu.Lock() + s.failNext = true + s.appendCalls = 0 + s.mu.Unlock() +} + +func (s *failAfterAppendOnceStore) Append(sessionID string, event protocol.EventRecord) error { + s.mu.Lock() + s.appendCalls++ + fail := s.failNext + s.failNext = false + s.mu.Unlock() + if err := s.Store.Append(sessionID, event); err != nil { + return err + } + if fail { + return errInjectedAppend + } + return nil +} + +func (s *failAfterAppendOnceStore) appendCallCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.appendCalls +} + +func (s *failAfterAppendAndConfirmationStore) failPostWriteAndConfirmation() { + s.mu.Lock() + s.failStage = 1 + s.appendCalls = 0 + s.mu.Unlock() +} + +func (s *failAfterAppendAndConfirmationStore) Append(sessionID string, event protocol.EventRecord) error { + s.mu.Lock() + s.appendCalls++ + forceConflict := s.forceConflict + s.forceConflict = false + stage := s.failStage + if stage > 0 { + s.failStage++ + if s.failStage > 2 { + s.failStage = 0 + } + } + s.mu.Unlock() + if forceConflict { + return rinruntime.ErrConflict + } + switch stage { + case 1: + if err := s.Store.Append(sessionID, event); err != nil { + return err + } + return errInjectedAppend + case 2: + return errInjectedAppend + default: + return s.Store.Append(sessionID, event) + } +} + +func (s *failAfterAppendAndConfirmationStore) forceNextAppendConflict() { + s.mu.Lock() + s.forceConflict = true + s.mu.Unlock() +} + +func (s *failAfterAppendAndConfirmationStore) appendCallCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.appendCalls +} + +func (s *corruptProposalReconcileOnceStore) failAfterWriteAndCorruptLoad() { + s.mu.Lock() + s.failNext = true + s.mu.Unlock() +} + +func (s *corruptProposalReconcileOnceStore) Append( + sessionID string, + event protocol.EventRecord, +) error { + s.mu.Lock() + fail := s.failNext && event.Type == rinruntime.EventProposed + if fail { + s.failNext = false + s.corruptNext = true + } + s.mu.Unlock() + if err := s.Store.Append(sessionID, event); err != nil { + return err + } + if fail { + return errInjectedAppend + } + return nil +} + +func (s *corruptProposalReconcileOnceStore) Load(sessionID string) ([]protocol.EventRecord, error) { + events, err := s.Store.Load(sessionID) + if err != nil { + return nil, err + } + s.mu.Lock() + corrupt := s.corruptNext + s.corruptNext = false + s.mu.Unlock() + if corrupt && len(events) > 0 { + events[len(events)-1].Hash = "corrupt-reconciliation-hash" + } + return events, nil +} + +func (s *failProposalBeforeWriteAndLoadOnceStore) failProposalAndLoad() { + s.mu.Lock() + s.failAppend = true + s.appendCalls = 0 + s.mu.Unlock() +} + +func (s *failProposalBeforeWriteAndLoadOnceStore) Append( + sessionID string, + event protocol.EventRecord, +) error { + s.mu.Lock() + s.appendCalls++ + fail := s.failAppend && event.Type == rinruntime.EventProposed + if fail { + s.failAppend = false + s.failLoad = true + } + s.mu.Unlock() + if fail { + return errInjectedAppend + } + return s.Store.Append(sessionID, event) +} + +func (s *failProposalBeforeWriteAndLoadOnceStore) Load( + sessionID string, +) ([]protocol.EventRecord, error) { + s.mu.Lock() + fail := s.failLoad + s.failLoad = false + s.mu.Unlock() + if fail { + return nil, errInjectedAppend + } + return s.Store.Load(sessionID) +} + +func (s *failProposalBeforeWriteAndLoadOnceStore) appendCallCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.appendCalls +} + +func (p *changingAtomicPolicy) Propose( + _ context.Context, + _ rinruntime.PolicyContext, +) (rinruntime.ProposalDraft, error) { + p.mu.Lock() + p.calls++ + call := p.calls + p.mu.Unlock() + actionID := "talk" + if call > 1 { + actionID = "wait" + } + return rinruntime.ProposalDraft{ + ActionID: actionID, + Stance: "engage", + Summary: "A deliberately changing policy result.", + Rationale: "Used to prove an uncertain append retry does not invoke policy twice.", + }, nil +} + +func (p *changingAtomicPolicy) callCount() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.calls +} + +func staleHashEventMutations() []struct { + name string + mutate func(*protocol.EventRecord) +} { + return []struct { + name string + mutate func(*protocol.EventRecord) + }{ + { + name: "data-bytes", + mutate: func(event *protocol.EventRecord) { + event.Data = append(event.Data, ' ') + }, + }, + { + name: "type", + mutate: func(event *protocol.EventRecord) { + event.Type += ".tampered" + }, + }, + { + name: "request-id", + mutate: func(event *protocol.EventRecord) { + event.RequestID += ".tampered" + }, + }, + { + name: "prev-hash", + mutate: func(event *protocol.EventRecord) { + event.PrevHash += "0" + }, + }, + { + name: "recorded-at", + mutate: func(event *protocol.EventRecord) { + event.RecordedAt += "0" + }, + }, + } +} diff --git a/runtime/engine.go b/runtime/engine.go index 79622b1..ad6c22a 100644 --- a/runtime/engine.go +++ b/runtime/engine.go @@ -1,6 +1,7 @@ package runtime import ( + "bytes" "context" "errors" "fmt" @@ -14,8 +15,14 @@ import ( ) type managedSession struct { - mu sync.Mutex - state protocol.SessionState + mu sync.Mutex + state protocol.SessionState + uncertainProposals map[string]uncertainProposalAppend +} + +type uncertainProposalAppend struct { + event protocol.EventRecord + requestHash string } type Engine struct { @@ -79,12 +86,9 @@ func (e *Engine) CreateSession(request protocol.CreateSessionRequest) (protocol. if err != nil { return protocol.MutationResult{}, NewError("event_encode_failed", "could not encode session event", err) } - state, err := applyEvent(protocol.SessionState{}, event) + state, err := e.createAndConfirm(request.SessionID, event) if err != nil { - return protocol.MutationResult{}, NewError("event_apply_failed", "could not initialize session", err) - } - if err := e.store.Create(request.SessionID, event); err != nil { - return protocol.MutationResult{}, NewError("store_create_failed", "could not create session log", err) + return protocol.MutationResult{}, err } e.sessions[request.SessionID] = &managedSession{state: state} return mutationResult(state, false), nil @@ -106,7 +110,8 @@ func (e *Engine) Observe(request protocol.ObserveRequest) (protocol.MutationResu } return protocol.MutationResult{}, requestConflict(request.RequestID) } - if request.Tick < session.state.Tick { + if !protocol.HasFeature(session.state.Features, protocol.FeatureOutcomeReporting) && + request.Tick < session.state.Tick { return protocol.MutationResult{}, NewFieldError("tick_regressed", "observation tick is older than session state", "tick", ErrConflict) } for _, actorID := range request.ObserverIDs { @@ -134,6 +139,14 @@ func (e *Engine) Propose(ctx context.Context, request protocol.ProposeRequest) ( if err := protocol.ValidatePropose(request); err != nil { return protocol.ActionProposal{}, false, validationError(err) } + requestHash, err := hashJSON(request) + if err != nil { + return protocol.ActionProposal{}, false, NewError( + "request_encode_failed", + "could not identify proposal request", + err, + ) + } session, err := e.session(request.SessionID) if err != nil { return protocol.ActionProposal{}, false, err @@ -141,6 +154,10 @@ func (e *Engine) Propose(ctx context.Context, request protocol.ProposeRequest) ( session.mu.Lock() if receipt, found := session.state.Receipts[request.RequestID]; found { if receipt.Kind == EventProposed { + if receipt.RequestHash != "" && receipt.RequestHash != requestHash { + session.mu.Unlock() + return protocol.ActionProposal{}, false, requestConflict(request.RequestID) + } proposal, exists := session.state.Proposals[receipt.EntityID] session.mu.Unlock() if !exists { @@ -151,6 +168,32 @@ func (e *Engine) Propose(ctx context.Context, request protocol.ProposeRequest) ( session.mu.Unlock() return protocol.ActionProposal{}, false, requestConflict(request.RequestID) } + if uncertain, found := session.uncertainProposals[request.RequestID]; found { + if uncertain.requestHash != requestHash { + session.mu.Unlock() + return protocol.ActionProposal{}, false, requestConflict(request.RequestID) + } + if err := e.appendAndApply(session, uncertain.event); err != nil { + session.mu.Unlock() + return protocol.ActionProposal{}, false, err + } + delete(session.uncertainProposals, request.RequestID) + receipt := session.state.Receipts[request.RequestID] + proposal, exists := session.state.Proposals[receipt.EntityID] + session.mu.Unlock() + if !exists { + return protocol.ActionProposal{}, false, NewError( + "proposal_missing", + "reconciled proposal is no longer retained", + ErrNotFound, + ) + } + return proposal, false, nil + } + if len(session.uncertainProposals) > 0 { + session.mu.Unlock() + return protocol.ActionProposal{}, false, unresolvedProposalError() + } actor, exists := session.state.Actors[request.ActorID] if !exists { session.mu.Unlock() @@ -170,6 +213,14 @@ func (e *Engine) Propose(ctx context.Context, request protocol.ProposeRequest) ( return protocol.ActionProposal{}, false, NewFieldError("goal_exists", "candidate goal is already part of actor state", fmt.Sprintf("candidate_goals[%d].id", index), ErrConflict) } } + if !canRetainAnotherProposal(session.state) { + session.mu.Unlock() + return protocol.ActionProposal{}, false, NewError( + "proposal_capacity", + "all retained proposal slots are pending; report or reject an outcome before proposing again", + ErrConflict, + ) + } if actor.Activity != nil && actor.Activity.State == "dormant" { session.mu.Unlock() return protocol.ActionProposal{}, false, NewError("actor_dormant", "actor is dormant and must be woken by the game", ErrNotDue) @@ -215,16 +266,51 @@ func (e *Engine) Propose(ctx context.Context, request protocol.ProposeRequest) ( } if receipt, found := session.state.Receipts[request.RequestID]; found { if receipt.Kind == EventProposed { - proposal := session.state.Proposals[receipt.EntityID] + if receipt.RequestHash != "" && receipt.RequestHash != requestHash { + return protocol.ActionProposal{}, false, requestConflict(request.RequestID) + } + proposal, exists := session.state.Proposals[receipt.EntityID] + if !exists { + return protocol.ActionProposal{}, false, NewError("proposal_missing", "idempotent proposal is no longer retained", ErrNotFound) + } return proposal, true, nil } return protocol.ActionProposal{}, false, requestConflict(request.RequestID) } + if uncertain, found := session.uncertainProposals[request.RequestID]; found { + if uncertain.requestHash != requestHash { + return protocol.ActionProposal{}, false, requestConflict(request.RequestID) + } + if err := e.appendAndApply(session, uncertain.event); err != nil { + return protocol.ActionProposal{}, false, err + } + delete(session.uncertainProposals, request.RequestID) + receipt := session.state.Receipts[request.RequestID] + proposal, exists := session.state.Proposals[receipt.EntityID] + if !exists { + return protocol.ActionProposal{}, false, NewError( + "proposal_missing", + "reconciled proposal is no longer retained", + ErrNotFound, + ) + } + return proposal, false, nil + } + if len(session.uncertainProposals) > 0 { + return protocol.ActionProposal{}, false, unresolvedProposalError() + } worldChanged := arbitrationEnabled && session.state.WorldRevision != baseWorldRevision legacyChanged := !arbitrationEnabled && (session.state.Revision != baseRevision || session.state.HeadHash != baseHash) if worldChanged || legacyChanged { return protocol.ActionProposal{}, false, NewError("state_changed", "session changed while policy was proposing; retry with a new request id", ErrStale) } + if !canRetainAnotherProposal(session.state) { + return protocol.ActionProposal{}, false, NewError( + "proposal_capacity", + "all retained proposal slots are pending; report or reject an outcome before proposing again", + ErrConflict, + ) + } proposalHash, err := hashJSON(struct { SessionID string `json:"session_id"` RequestID string `json:"request_id"` @@ -254,11 +340,26 @@ func (e *Engine) Propose(ctx context.Context, request protocol.ProposeRequest) ( ProposedGoal: proposedGoal, Status: "pending", } - event, err := newEvent(session.state, EventProposed, request.RequestID, proposedPayload{Proposal: proposal}, e.now()) + event, err := newEvent( + session.state, + EventProposed, + request.RequestID, + proposedPayload{Proposal: proposal, RequestHash: requestHash}, + e.now(), + ) if err != nil { return protocol.ActionProposal{}, false, NewError("event_encode_failed", "could not encode proposal", err) } if err := e.appendAndApply(session, event); err != nil { + if ErrorCode(err) == "proposal_outcome_unknown" { + if session.uncertainProposals == nil { + session.uncertainProposals = make(map[string]uncertainProposalAppend) + } + session.uncertainProposals[request.RequestID] = uncertainProposalAppend{ + event: event, + requestHash: requestHash, + } + } return protocol.ActionProposal{}, false, err } return proposal, false, nil @@ -287,15 +388,53 @@ func (e *Engine) Commit(request protocol.CommitRequest) (protocol.MutationResult if proposal.Status != "pending" { return protocol.MutationResult{}, NewFieldError("proposal_resolved", "proposal was already resolved", "proposal_id", ErrConflict) } - worldRevisionMismatch := proposal.BasedOnWorldRevision > 0 && proposal.BasedOnWorldRevision != session.state.WorldRevision - legacyRevisionMismatch := proposal.BasedOnWorldRevision == 0 && proposal.CreatedRevision != session.state.Revision - if request.Accepted && (worldRevisionMismatch || legacyRevisionMismatch) { - return protocol.MutationResult{}, NewError("proposal_stale", "session changed after the proposal was created", ErrStale) + outcomeReporting := protocol.HasFeature(session.state.Features, protocol.FeatureOutcomeReporting) + if outcomeReporting { + // Commit is the game authority's report of an outcome that it has + // already applied or rejected. State may advance before that report + // arrives, so only reject an impossible occurrence time. + if request.Tick < proposal.Tick { + return protocol.MutationResult{}, NewFieldError("tick_regressed", "commit tick is older than its proposal", "tick", ErrConflict) + } + } else { + // Preserve pre-feature event-log and API semantics for existing + // sessions. New integrations opt in through outcome-reporting-v1. + worldRevisionMismatch := proposal.BasedOnWorldRevision > 0 && + proposal.BasedOnWorldRevision != session.state.WorldRevision + legacyRevisionMismatch := proposal.BasedOnWorldRevision == 0 && + proposal.CreatedRevision != session.state.Revision + if request.Accepted && (worldRevisionMismatch || legacyRevisionMismatch) { + return protocol.MutationResult{}, NewError("proposal_stale", "session changed after the proposal was created", ErrStale) + } + if request.Tick < session.state.Tick || request.Tick < proposal.Tick { + return protocol.MutationResult{}, NewFieldError("tick_regressed", "commit tick is older than its proposal or session", "tick", ErrConflict) + } } - if request.Tick < session.state.Tick || request.Tick < proposal.Tick { - return protocol.MutationResult{}, NewFieldError("tick_regressed", "commit tick is older than its proposal or session", "tick", ErrConflict) + if outcomeReporting { + if !request.Accepted && (len(request.Facts) > 0 || len(request.GoalUpdates) > 0) { + return protocol.MutationResult{}, NewFieldError( + "rejected_outcome_updates", + "rejected outcomes cannot carry facts or goal updates; report observations separately", + "accepted", + ErrConflict, + ) + } + if duplicateGoalUpdate(request.GoalUpdates) { + return protocol.MutationResult{}, NewFieldError( + "duplicate_goal_update", + "goal updates must contain at most one entry per goal", + "goal_updates", + ErrConflict, + ) + } + } + if eventIDExists(session.state, request.EventID) { + return protocol.MutationResult{}, NewFieldError("event_exists", "event id was already observed or reported", "event_id", ErrConflict) } actor := session.state.Actors[proposal.ActorID] + if request.Accepted && request.Tick > maxInt64-actor.ThinkEveryTicks { + return protocol.MutationResult{}, NewFieldError("tick_overflow", "commit tick cannot be scheduled safely", "tick", ErrConflict) + } for index, update := range request.GoalUpdates { if !goalExists(actor, update.GoalID) && (proposal.ProposedGoal == nil || proposal.ProposedGoal.ID != update.GoalID) { return protocol.MutationResult{}, NewFieldError("unknown_goal", "goal update references an unknown goal", fmt.Sprintf("goal_updates[%d].goal_id", index), ErrNotFound) @@ -330,10 +469,13 @@ func (e *Engine) CommitBatch(request protocol.BatchCommitRequest) (protocol.Muta } return protocol.MutationResult{}, requestConflict(request.RequestID) } - if request.Tick < session.state.Tick { + outcomeReporting := protocol.HasFeature(session.state.Features, protocol.FeatureOutcomeReporting) + if !outcomeReporting && request.Tick < session.state.Tick { return protocol.MutationResult{}, NewFieldError("tick_regressed", "batch commit tick is older than session state", "tick", ErrConflict) } actors := make(map[string]struct{}, len(request.Items)) + eventIDs := make(map[string]struct{}, len(request.Items)) + var baseWorldRevision uint64 for index, item := range request.Items { proposal, exists := session.state.Proposals[item.ProposalID] if !exists { @@ -342,12 +484,40 @@ func (e *Engine) CommitBatch(request protocol.BatchCommitRequest) (protocol.Muta if proposal.Status != "pending" { return protocol.MutationResult{}, NewFieldError("proposal_resolved", "batch item references a resolved proposal", fmt.Sprintf("items[%d].proposal_id", index), ErrConflict) } - if proposal.BasedOnWorldRevision == 0 || proposal.BasedOnWorldRevision != session.state.WorldRevision { + if outcomeReporting { + if proposal.BasedOnWorldRevision == 0 { + return protocol.MutationResult{}, NewFieldError("proposal_base_mismatch", "batch proposals must identify one world revision", "items", ErrConflict) + } + if index == 0 { + baseWorldRevision = proposal.BasedOnWorldRevision + } else if proposal.BasedOnWorldRevision != baseWorldRevision { + return protocol.MutationResult{}, NewFieldError("proposal_base_mismatch", "batch proposals were created from different world revisions", "items", ErrConflict) + } + } else if proposal.BasedOnWorldRevision == 0 || + proposal.BasedOnWorldRevision != session.state.WorldRevision { return protocol.MutationResult{}, NewError("proposal_stale", "batch contains a proposal from another world revision", ErrStale) } if request.Tick < proposal.Tick { return protocol.MutationResult{}, NewFieldError("tick_regressed", "batch commit tick is older than a proposal", "tick", ErrConflict) } + if outcomeReporting { + if !item.Accepted && (len(item.Facts) > 0 || len(item.GoalUpdates) > 0) { + return protocol.MutationResult{}, NewFieldError( + "rejected_outcome_updates", + "rejected outcomes cannot carry facts or goal updates; report observations separately", + fmt.Sprintf("items[%d].accepted", index), + ErrConflict, + ) + } + if duplicateGoalUpdate(item.GoalUpdates) { + return protocol.MutationResult{}, NewFieldError( + "duplicate_goal_update", + "goal updates must contain at most one entry per goal", + fmt.Sprintf("items[%d].goal_updates", index), + ErrConflict, + ) + } + } if _, duplicate := actors[proposal.ActorID]; duplicate { return protocol.MutationResult{}, NewFieldError("duplicate_actor", "batch may contain at most one proposal per actor", "items", ErrConflict) } @@ -355,7 +525,14 @@ func (e *Engine) CommitBatch(request protocol.BatchCommitRequest) (protocol.Muta if eventIDExists(session.state, item.EventID) { return protocol.MutationResult{}, NewFieldError("event_exists", "batch event id was already observed", fmt.Sprintf("items[%d].event_id", index), ErrConflict) } + if _, duplicate := eventIDs[item.EventID]; duplicate { + return protocol.MutationResult{}, NewFieldError("event_exists", "batch event ids must be unique", fmt.Sprintf("items[%d].event_id", index), ErrConflict) + } + eventIDs[item.EventID] = struct{}{} actor := session.state.Actors[proposal.ActorID] + if item.Accepted && request.Tick > maxInt64-actor.ThinkEveryTicks { + return protocol.MutationResult{}, NewFieldError("tick_overflow", "batch commit tick cannot be scheduled safely", "tick", ErrConflict) + } for goalIndex, update := range item.GoalUpdates { if !goalExists(actor, update.GoalID) && (proposal.ProposedGoal == nil || proposal.ProposedGoal.ID != update.GoalID) { return protocol.MutationResult{}, NewFieldError("unknown_goal", "goal update references an unknown goal", fmt.Sprintf("items[%d].goal_updates[%d].goal_id", index, goalIndex), ErrNotFound) @@ -531,14 +708,10 @@ func (e *Engine) Restore(request protocol.RestoreRequest) (protocol.MutationResu e.mu.Unlock() return protocol.MutationResult{}, NewError("event_encode_failed", "could not encode restore", err) } - state, err := applyEvent(protocol.SessionState{}, event) + state, err := e.createAndConfirm(request.SessionID, event) if err != nil { e.mu.Unlock() - return protocol.MutationResult{}, NewError("event_apply_failed", "could not restore session", err) - } - if err := e.store.Create(request.SessionID, event); err != nil { - e.mu.Unlock() - return protocol.MutationResult{}, NewError("store_create_failed", "could not create restored session log", err) + return protocol.MutationResult{}, err } e.sessions[request.SessionID] = &managedSession{state: state} e.mu.Unlock() @@ -690,6 +863,17 @@ func uniqueSorted(values []string) []string { return result } +func duplicateGoalUpdate(updates []protocol.GoalUpdate) bool { + seen := make(map[string]struct{}, len(updates)) + for _, update := range updates { + if _, exists := seen[update.GoalID]; exists { + return true + } + seen[update.GoalID] = struct{}{} + } + return false +} + func (e *Engine) session(id string) (*managedSession, error) { e.mu.RLock() session, exists := e.sessions[id] @@ -701,17 +885,194 @@ func (e *Engine) session(id string) (*managedSession, error) { } func (e *Engine) appendAndApply(session *managedSession, event protocol.EventRecord) error { - state, err := applyEvent(session.state, event) + if len(session.uncertainProposals) > 0 && !isUncertainProposalRetry(session, event) { + return unresolvedProposalError() + } + candidate, err := clone(session.state) + if err != nil { + return NewError("state_copy_failed", "could not prepare an isolated state transition", err) + } + // JSON cloning intentionally drops empty `omitempty` maps. Reducers require + // these indexes to be writable even before their first entry is recorded. + if candidate.Proposals == nil { + candidate.Proposals = make(map[string]protocol.ActionProposal) + } + if candidate.Receipts == nil { + candidate.Receipts = make(map[string]protocol.RequestReceipt) + } + state, err := applyEvent(candidate, event) if err != nil { return NewError("event_apply_failed", "event could not be applied", err) } - if err := e.store.Append(session.state.SessionID, event); err != nil { - return NewError("store_append_failed", "could not persist event", err) + if appendErr := e.store.Append(session.state.SessionID, event); appendErr != nil { + events, loadErr := e.store.Load(session.state.SessionID) + if loadErr == nil { + tail, reconciledState, matched, reconcileErr := reconcileTail(session.state, events, event) + if reconcileErr != nil { + if event.Type == EventProposed { + return NewError( + "proposal_outcome_unknown", + "persisted proposal event could not be reconciled; retry the same request id", + errors.Join(appendErr, reconcileErr), + ) + } + return NewError("event_apply_failed", "persisted event could not be reconciled", reconcileErr) + } + if matched { + // Append may report a post-write Sync/Close failure. Standard + // stores make an exact append idempotent, so retry the persisted + // bytes to confirm durability. A later client retry can also + // reconcile the same logical event even though RecordedAt and + // Hash were regenerated. + if retryErr := e.store.Append(session.state.SessionID, tail); retryErr == nil { + session.state = reconciledState + return nil + } else { + if event.Type == EventProposed { + return NewError( + "proposal_outcome_unknown", + "proposal event may be durable but could not be confirmed; retry the same request id", + errors.Join(appendErr, retryErr), + ) + } + return NewError( + "store_append_failed", + "event was written but its durable append could not be confirmed", + errors.Join(appendErr, retryErr), + ) + } + } + } + if loadErr != nil { + appendErr = errors.Join(appendErr, loadErr) + if event.Type == EventProposed { + return NewError( + "proposal_outcome_unknown", + "proposal persistence could not be determined; retry the same request id", + appendErr, + ) + } + } + return NewError("store_append_failed", "could not persist event", appendErr) } session.state = state return nil } +func isUncertainProposalRetry(session *managedSession, event protocol.EventRecord) bool { + for _, uncertain := range session.uncertainProposals { + if EventRecordsExactlyEqual(uncertain.event, event) { + return true + } + } + return false +} + +func unresolvedProposalError() error { + return NewError( + "proposal_outcome_unknown", + "session has an unresolved proposal append; retry the same proposal request id before mutating it", + ErrConflict, + ) +} + +func (e *Engine) createAndConfirm( + sessionID string, + event protocol.EventRecord, +) (protocol.SessionState, error) { + candidate, applyErr := applyEvent(protocol.SessionState{}, event) + if applyErr != nil { + return protocol.SessionState{}, NewError( + "event_apply_failed", + "session event could not be applied", + applyErr, + ) + } + createErr := e.store.Create(sessionID, event) + if createErr == nil { + return candidate, nil + } + events, loadErr := e.store.Load(sessionID) + if loadErr == nil && len(events) == 1 { + persisted := events[0] + sameSequenceAndHash := persisted.Sequence == event.Sequence && persisted.Hash == event.Hash + if sameSequenceAndHash || eventsLogicallyEqual(persisted, event) { + reconciled, applyErr := applyEvent(protocol.SessionState{}, persisted) + if applyErr != nil { + return protocol.SessionState{}, NewError( + "event_apply_failed", + "persisted session event could not be reconciled", + applyErr, + ) + } + if retryErr := e.store.Create(sessionID, persisted); retryErr == nil { + return reconciled, nil + } else { + return protocol.SessionState{}, NewError( + "store_create_failed", + "session event was written but its durable create could not be confirmed", + errors.Join(createErr, retryErr), + ) + } + } + } + if loadErr != nil { + createErr = errors.Join(createErr, loadErr) + } + return protocol.SessionState{}, NewError("store_create_failed", "could not create session log", createErr) +} + +func reconcileTail( + current protocol.SessionState, + events []protocol.EventRecord, + event protocol.EventRecord, +) (protocol.EventRecord, protocol.SessionState, bool, error) { + if len(events) == 0 { + return protocol.EventRecord{}, protocol.SessionState{}, false, nil + } + tail := events[len(events)-1] + sameSequenceAndHash := tail.Sequence == event.Sequence && tail.Hash == event.Hash + if !sameSequenceAndHash && !eventsLogicallyEqual(tail, event) { + return protocol.EventRecord{}, protocol.SessionState{}, false, nil + } + reconciled, err := clone(current) + if err != nil { + return protocol.EventRecord{}, protocol.SessionState{}, false, err + } + if reconciled.Proposals == nil { + reconciled.Proposals = make(map[string]protocol.ActionProposal) + } + if reconciled.Receipts == nil { + reconciled.Receipts = make(map[string]protocol.RequestReceipt) + } + reconciled, err = applyEvent(reconciled, tail) + if err != nil { + return protocol.EventRecord{}, protocol.SessionState{}, false, err + } + return tail, reconciled, true, nil +} + +// EventRecordsExactlyEqual defines durable Store idempotency for an +// EventRecord. Data is intentionally compared as persisted bytes rather than +// as semantically equivalent JSON. +func EventRecordsExactlyEqual(left, right protocol.EventRecord) bool { + return left.Sequence == right.Sequence && + left.Type == right.Type && + left.RequestID == right.RequestID && + left.PrevHash == right.PrevHash && + left.Hash == right.Hash && + left.RecordedAt == right.RecordedAt && + bytes.Equal(left.Data, right.Data) +} + +func eventsLogicallyEqual(left, right protocol.EventRecord) bool { + return left.Sequence == right.Sequence && + left.Type == right.Type && + left.RequestID == right.RequestID && + left.PrevHash == right.PrevHash && + bytes.Equal(left.Data, right.Data) +} + func mutationResult(state protocol.SessionState, duplicate bool) protocol.MutationResult { return protocol.MutationResult{SessionID: state.SessionID, Revision: state.Revision, HeadHash: state.HeadHash, Duplicate: duplicate} } @@ -734,6 +1095,11 @@ func duplicateReceipt(state protocol.SessionState, requestID, kind string) bool } func eventIDExists(state protocol.SessionState, eventID string) bool { + for _, proposal := range state.Proposals { + if proposal.OutcomeEventID == eventID { + return true + } + } for _, actor := range state.Actors { for _, memory := range actor.Memories { if memory.EventID == eventID { @@ -758,6 +1124,18 @@ func goalExists(actor protocol.ActorState, goalID string) bool { return false } +func canRetainAnotherProposal(state protocol.SessionState) bool { + if len(state.Proposals) < maxProposals { + return true + } + for _, proposal := range state.Proposals { + if proposal.Status != "pending" { + return true + } + } + return false +} + func validateDraft(request protocol.ProposeRequest, actor protocol.ActorState, draft ProposalDraft) (protocol.ActionSpec, *protocol.Goal, error) { var selected protocol.ActionSpec found := false diff --git a/runtime/engine_test.go b/runtime/engine_test.go index d4c72b6..5f4b6ad 100644 --- a/runtime/engine_test.go +++ b/runtime/engine_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sync" "testing" "time" @@ -99,6 +100,140 @@ func TestEngineEndToEnd(t *testing.T) { } } +func TestOutcomeFeatureRejectsAmbiguousCommitUpdates(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + const sessionID = "session.outcome-update-validation" + if _, err := engine.CreateSession(createRequest(sessionID)); err != nil { + t.Fatal(err) + } + proposal, _, err := engine.Propose( + context.Background(), + proposeRequest(sessionID, "propose.outcome-update-validation", 0, nil), + ) + if err != nil { + t.Fatal(err) + } + base := protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + ProposalID: proposal.ID, + Tick: proposal.Tick, + } + rejected := base + rejected.RequestID = "commit.rejected-with-updates" + rejected.EventID = "event.rejected-with-updates" + rejected.Facts = []protocol.Fact{{ + SubjectID: "player", Predicate: "attempted", Object: "locked-door", Confidence: 100, + }} + if _, err := engine.Commit(rejected); rinruntime.ErrorCode(err) != "rejected_outcome_updates" { + t.Fatalf("rejected outcome updates should fail explicitly, got %v", err) + } + + duplicate := base + duplicate.RequestID = "commit.duplicate-goal-updates" + duplicate.EventID = "event.duplicate-goal-updates" + duplicate.Accepted = true + duplicate.Outcome = "The action happened." + duplicate.GoalUpdates = []protocol.GoalUpdate{ + {GoalID: "goal.connect", ProgressDelta: 1}, + {GoalID: "goal.connect", ProgressDelta: -1}, + } + if _, err := engine.Commit(duplicate); rinruntime.ErrorCode(err) != "duplicate_goal_update" { + t.Fatalf("duplicate new-semantics goal updates should fail explicitly, got %v", err) + } +} + +func TestLegacyCommitPreservesRepeatedGoalUpdateBehavior(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + const sessionID = "session.legacy-repeated-goal-update" + create := createRequest(sessionID) + create.Features = nil + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + proposal, _, err := engine.Propose( + context.Background(), + proposeRequest(sessionID, "propose.legacy-repeated-goal-update", 0, nil), + ) + if err != nil { + t.Fatal(err) + } + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.legacy-repeated-goal-update", + ProposalID: proposal.ID, + EventID: "event.legacy-repeated-goal-update", + Tick: proposal.Tick, + Accepted: true, + Outcome: "The legacy action happened.", + GoalUpdates: []protocol.GoalUpdate{ + {GoalID: "goal.connect", ProgressDelta: 1}, + {GoalID: "goal.connect", ProgressDelta: 1}, + }, + }); err != nil { + t.Fatalf("pre-feature repeated goal updates should retain legacy behavior: %v", err) + } + state, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if progress := state.Actors["npc.mira"].Goals[0].Progress; progress != 3 { + t.Fatalf("legacy repeated updates produced progress %d, want 3", progress) + } +} + +func TestLegacyStateRejectsInjectedOutcomeOccurrenceMetadata(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + const sessionID = "session.legacy-metadata-gate" + create := createRequest(sessionID) + create.Features = nil + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + observation := observeRequest(sessionID, "observe.legacy-metadata", "event.legacy-metadata", 1) + observation.Facts = []protocol.Fact{{ + SubjectID: "player", Predicate: "respected_boundary", Object: "yes", Confidence: 100, + }} + if _, err := engine.Observe(observation); err != nil { + t.Fatal(err) + } + baseline, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if err := protocol.ValidateSessionState(baseline); err != nil { + t.Fatalf("legacy baseline state is invalid: %v", err) + } + + withGoalMetadata := baseline + actor := withGoalMetadata.Actors["npc.mira"] + actor.Goals = append([]protocol.Goal(nil), actor.Goals...) + actor.Goals[0].UpdatedTick = 1 + withGoalMetadata.Actors["npc.mira"] = actor + if err := protocol.ValidateSessionState(withGoalMetadata); err == nil { + t.Fatal("legacy state accepted injected goal occurrence metadata") + } + + baseline, err = engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + withFactMetadata := baseline + actor = withFactMetadata.Actors["npc.mira"] + actor.Beliefs = make(map[string]protocol.Fact, len(actor.Beliefs)) + for key, fact := range baseline.Actors["npc.mira"].Beliefs { + actor.Beliefs[key] = fact + } + fact := actor.Beliefs["player:respected_boundary"] + fact.ObservedTick = 1 + actor.Beliefs["player:respected_boundary"] = fact + withFactMetadata.Actors["npc.mira"] = actor + if err := protocol.ValidateSessionState(withFactMetadata); err == nil { + t.Fatal("legacy state accepted injected fact occurrence metadata") + } +} + func TestBoundaryRequiresSafeCandidate(t *testing.T) { engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) _, _ = engine.CreateSession(createRequest("session.boundary")) @@ -119,44 +254,122 @@ func TestBoundaryRequiresSafeCandidate(t *testing.T) { } } -func TestAcceptedProposalBecomesStaleAfterObservation(t *testing.T) { +func TestCommitReportsAcceptedOutcomeAfterStateAdvances(t *testing.T) { engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) - _, _ = engine.CreateSession(createRequest("session.stale")) - proposal, _, err := engine.Propose(context.Background(), proposeRequest("session.stale", "propose.stale", 0, nil)) + _, _ = engine.CreateSession(createRequest("session.late-accepted")) + proposal, _, err := engine.Propose(context.Background(), proposeRequest("session.late-accepted", "propose.late", 0, nil)) if err != nil { t.Fatal(err) } - _, err = engine.Observe(observeRequest("session.stale", "observe.after", "event.after", 0)) + _, err = engine.Observe(observeRequest("session.late-accepted", "observe.after", "event.after", 5)) if err != nil { t.Fatal(err) } commit := protocol.CommitRequest{ ProtocolVersion: protocol.Version, - SessionID: "session.stale", - RequestID: "commit.stale", + SessionID: "session.late-accepted", + RequestID: "commit.late", ProposalID: proposal.ID, - EventID: "event.commit.stale", + EventID: "event.commit.late", Tick: 0, Accepted: true, - Outcome: "Should not happen.", + Outcome: "The game already applied the action.", } - _, err = engine.Commit(commit) - if !errors.Is(err, rinruntime.ErrStale) { - t.Fatalf("expected stale proposal, got %v", err) + if _, err := engine.Commit(commit); err != nil { + t.Fatalf("late outcome should be recorded: %v", err) + } + state, err := engine.State(sessionRequest(commit.SessionID)) + if err != nil { + t.Fatal(err) + } + actor := state.Actors[proposal.ActorID] + if state.Tick != 5 || state.Proposals[proposal.ID].Status != "accepted" { + t.Fatalf("late outcome regressed state: %+v", state) + } + if len(actor.RecentActions) != 1 || len(actor.Memories) != 2 { + t.Fatalf("accepted outcome was not applied exactly once: %+v", actor) + } + var outcome *protocol.Memory + for index := range actor.Memories { + if actor.Memories[index].EventID == commit.EventID { + outcome = &actor.Memories[index] + break + } + } + if outcome == nil || outcome.Tick != commit.Tick { + t.Fatalf("outcome did not preserve its occurrence time: %+v", actor.Memories) + } +} + +func TestCommitReportsRejectedOutcomeAfterStateAdvances(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + _, _ = engine.CreateSession(createRequest("session.late-rejected")) + proposal, _, err := engine.Propose(context.Background(), proposeRequest("session.late-rejected", "propose.reject", 0, nil)) + if err != nil { + t.Fatal(err) + } + if _, err := engine.Observe(observeRequest("session.late-rejected", "observe.after", "event.after", 5)); err != nil { + t.Fatal(err) + } + before, _ := engine.State(sessionRequest("session.late-rejected")) + commit := protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: "session.late-rejected", + RequestID: "commit.reject", + ProposalID: proposal.ID, + EventID: "event.commit.reject", + Tick: 0, + Accepted: false, + Outcome: "The game rejected the action.", } - commit.RequestID = "commit.reject" - commit.EventID = "event.commit.reject" - commit.Accepted = false - commit.Outcome = "" if _, err := engine.Commit(commit); err != nil { - t.Fatalf("stale proposal should remain rejectable: %v", err) + t.Fatalf("late rejection should be recorded: %v", err) + } + after, _ := engine.State(sessionRequest(commit.SessionID)) + if after.Tick != before.Tick || after.Proposals[proposal.ID].Status != "rejected" { + t.Fatalf("rejected outcome was not settled correctly: %+v", after) + } + actorBefore := before.Actors[proposal.ActorID] + actorAfter := after.Actors[proposal.ActorID] + if len(actorAfter.Memories) != len(actorBefore.Memories) || len(actorAfter.RecentActions) != len(actorBefore.RecentActions) { + t.Fatalf("rejected outcome applied accepted-only side effects: before=%+v after=%+v", actorBefore, actorAfter) + } +} + +func TestCommitRejectsTickBeforeProposal(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + _, _ = engine.CreateSession(createRequest("session.commit-tick")) + proposal, _, err := engine.Propose(context.Background(), proposeRequest("session.commit-tick", "propose.tick", 2, nil)) + if err != nil { + t.Fatal(err) + } + _, err = engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: "session.commit-tick", + RequestID: "commit.tick", + ProposalID: proposal.ID, + EventID: "event.commit.tick", + Tick: 1, + Accepted: true, + Outcome: "Impossible occurrence time.", + }) + if !errors.Is(err, rinruntime.ErrConflict) || rinruntime.ErrorCode(err) != "tick_regressed" { + t.Fatalf("expected tick_regressed, got %v", err) + } + state, _ := engine.State(sessionRequest("session.commit-tick")) + if state.Proposals[proposal.ID].Status != "pending" { + t.Fatalf("invalid outcome resolved proposal: %+v", state.Proposals[proposal.ID]) } } func TestSnapshotTamperAndFreshRestore(t *testing.T) { engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) _, _ = engine.CreateSession(createRequest("session.snapshot")) - _, _ = engine.Observe(observeRequest("session.snapshot", "observe.snapshot", "event.snapshot", 4)) + newerObservation := observeRequest("session.snapshot", "observe.snapshot", "event.snapshot", 4) + newerObservation.Facts = []protocol.Fact{{ + SubjectID: "door", Predicate: "state", Object: "open", Confidence: 80, + }} + _, _ = engine.Observe(newerObservation) snapshot, err := engine.Snapshot(sessionRequest("session.snapshot")) if err != nil { t.Fatal(err) @@ -202,6 +415,244 @@ func TestSnapshotTamperAndFreshRestore(t *testing.T) { if state.Tick != 4 || len(state.Actors["npc.mira"].Memories) != 1 || len(state.Proposals) != 0 { t.Fatalf("unexpected restored state: %+v", state) } + reconciliation := observeRequest( + "session.snapshot", + "observe.offline-reconciliation", + "event.offline-reconciliation", + 2, + ) + reconciliation.Facts = []protocol.Fact{{ + SubjectID: "door", Predicate: "state", Object: "closed", Confidence: 100, + }} + if _, err := restoredEngine.Observe(reconciliation); err != nil { + t.Fatalf("late authoritative reconciliation after restore should succeed: %v", err) + } + state, err = restoredEngine.State(sessionRequest("session.snapshot")) + if err != nil { + t.Fatal(err) + } + actor = state.Actors["npc.mira"] + if state.Tick != 4 || + len(actor.Memories) != 2 || + actor.Memories[0].EventID != reconciliation.EventID || + actor.Beliefs["door:state"].Object != "open" { + t.Fatalf("late reconciliation regressed restored state: %+v", state) + } + reconciledSnapshot, err := restoredEngine.Snapshot(sessionRequest("session.snapshot")) + if err != nil { + t.Fatal(err) + } + if err := rinruntime.ValidateSnapshot(reconciledSnapshot); err != nil { + t.Fatalf("reconciled restore snapshot must validate: %v", err) + } +} + +func TestFreshRestoreRetainsPendingProposalForSavedOutcomeOutbox(t *testing.T) { + source := newEngine(t, store.NewMemory(), policy.Deterministic{}) + const sessionID = "session.restore-outbox" + if _, err := source.CreateSession(createRequest(sessionID)); err != nil { + t.Fatal(err) + } + proposal, _, err := source.Propose( + context.Background(), + proposeRequest(sessionID, "propose.restore-outbox", 7, nil), + ) + if err != nil { + t.Fatal(err) + } + snapshot, err := source.Snapshot(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + snapshot.State.Receipts = make(map[string]protocol.RequestReceipt, 1024) + for index := 0; index < 1024; index++ { + requestID := fmt.Sprintf("legacy.receipt.%04d", index) + snapshot.State.Receipts[requestID] = protocol.RequestReceipt{ + Kind: rinruntime.EventObserved, + EntityID: fmt.Sprintf("legacy.event.%04d", index), + Revision: snapshot.State.Revision + uint64(index), + } + } + snapshot, err = rinruntime.SnapshotOf(snapshot.State) + if err != nil { + t.Fatal(err) + } + + restored := newEngine(t, store.NewMemory(), policy.Deterministic{}) + restoreRequest := protocol.RestoreRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "restore.outbox", + Snapshot: snapshot, + } + if _, err := restored.Restore(restoreRequest); err != nil { + t.Fatal(err) + } + repeatedRestore, err := restored.Restore(restoreRequest) + if err != nil || !repeatedRestore.Duplicate { + t.Fatalf("full-receipt restore retry must be idempotent: result=%+v err=%v", repeatedRestore, err) + } + state, err := restored.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if got, exists := state.Proposals[proposal.ID]; !exists || got.Status != "pending" { + t.Fatalf("restore discarded the saved pending proposal: %+v", state.Proposals) + } + + commitRequest := protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.restore-outbox", + ProposalID: proposal.ID, + EventID: "event.restore-outbox", + Tick: 7, + Accepted: true, + Outcome: "The saved game had already applied this action.", + GoalUpdates: []protocol.GoalUpdate{{ + GoalID: "goal.connect", ProgressDelta: 4, Status: "completed", + }}, + } + firstCommit, err := restored.Commit(commitRequest) + if err != nil { + t.Fatalf("saved outcome report after restore failed: %v", err) + } + repeatedCommit, err := restored.Commit(commitRequest) + if err != nil || !repeatedCommit.Duplicate || repeatedCommit.Revision != firstCommit.Revision { + t.Fatalf("full-receipt outcome retry must be idempotent: first=%+v repeated=%+v err=%v", firstCommit, repeatedCommit, err) + } + state, err = restored.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + actor := state.Actors[proposal.ActorID] + goal, found := findGoal(actor, "goal.connect") + resolved := state.Proposals[proposal.ID] + if len(actor.RecentActions) != 1 { + t.Fatalf("restored outbox recent actions = %+v, want one", actor.RecentActions) + } + recent := actor.RecentActions[0] + if !found || + goal.ProgressAccumulator != 5 || + goal.Progress != 3 || + goal.Status != "completed" || + !goal.StatusExplicit || + goal.UpdatedTick != 7 || + goal.StatusUpdatedTick != 7 || + goal.StatusSourceEventID != "event.restore-outbox" || + actor.NextThinkTick != 12 || + resolved.Status != "accepted" || + resolved.OutcomeEventID != "event.restore-outbox" || + resolved.OutcomeTick != 7 || + recent.ID != proposal.ID || + recent.Status != "accepted" || + recent.OutcomeEventID != "event.restore-outbox" || + recent.OutcomeTick != 7 { + t.Fatalf( + "restored outbox did not reconcile complete outcome state: actor=%+v goal=%+v proposal=%+v recent=%+v", + actor, + goal, + resolved, + recent, + ) + } + finalSnapshot, err := restored.Snapshot(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if err := rinruntime.ValidateSnapshot(finalSnapshot); err != nil { + t.Fatalf("restored outcome state must remain snapshot-compatible: %v", err) + } +} + +func TestFreshRestoreRebasesArrivalRevisionsWithinTheNewEventChain(t *testing.T) { + source := newEngine(t, store.NewMemory(), policy.Deterministic{}) + const sessionID = "session.restore-revision-generation" + create := createRequest(sessionID) + create.Features = append(create.Features, protocol.FeatureBeliefConflicts) + if _, err := source.CreateSession(create); err != nil { + t.Fatal(err) + } + for index := 0; index < 10; index++ { + request := observeRequest( + sessionID, + fmt.Sprintf("observe.restore-filler.%d", index), + fmt.Sprintf("event.restore-filler.%d", index), + 0, + ) + if _, err := source.Observe(request); err != nil { + t.Fatal(err) + } + } + oldFact := observeRequest(sessionID, "observe.restore-old", "event.restore-alpha", 5) + oldFact.Facts = []protocol.Fact{{ + SubjectID: "gate", Predicate: "state", Object: "closed", Confidence: 80, + }} + if _, err := source.Observe(oldFact); err != nil { + t.Fatal(err) + } + snapshot, err := source.Snapshot(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + + restored := newEngine(t, store.NewMemory(), policy.Deterministic{}) + if _, err := restored.Restore(protocol.RestoreRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "restore.revision-generation", + Snapshot: snapshot, + }); err != nil { + t.Fatal(err) + } + newFact := observeRequest(sessionID, "observe.restore-new", "event.restore-zulu", 5) + newFact.Facts = []protocol.Fact{{ + SubjectID: "gate", Predicate: "state", Object: "open", Confidence: 80, + }} + if _, err := restored.Observe(newFact); err != nil { + t.Fatal(err) + } + state, err := restored.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + actor := state.Actors["npc.mira"] + if selected := actor.Beliefs["gate:state"]; selected.SourceEventID != newFact.EventID { + t.Fatalf("old-chain revision defeated deterministic same-tick fact tie-break: %+v", selected) + } + set := actor.BeliefSets["gate:state"] + revisions := make(map[string]uint64, len(set.Claims)) + for _, claim := range set.Claims { + revisions[claim.Fact.SourceEventID] = claim.ObservedRevision + } + if revisions[oldFact.EventID] != 1 || revisions[newFact.EventID] != 2 { + t.Fatalf("belief revisions were not rebased into the new chain: %+v", revisions) + } + oldIndex, newIndex := -1, -1 + for index, memory := range actor.Memories { + switch memory.EventID { + case oldFact.EventID: + oldIndex = index + if memory.CreatedRevision != 1 { + t.Fatalf("old memory revision = %d, want restore revision 1", memory.CreatedRevision) + } + case newFact.EventID: + newIndex = index + if memory.CreatedRevision != 2 { + t.Fatalf("new memory revision = %d, want 2", memory.CreatedRevision) + } + } + } + if oldIndex < 0 || newIndex < 0 || oldIndex >= newIndex { + t.Fatalf("same-tick memories are not ordered by the new chain: old=%d new=%d", oldIndex, newIndex) + } + finalSnapshot, err := restored.Snapshot(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if err := rinruntime.ValidateSnapshot(finalSnapshot); err != nil { + t.Fatalf("rebased restore state must remain snapshot-compatible: %v", err) + } } func TestMemoryIsBounded(t *testing.T) { @@ -223,6 +674,46 @@ func TestMemoryIsBounded(t *testing.T) { } } +func TestPendingProposalCapacityFailsClosedAndSnapshotRemainsValid(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + const sessionID = "session.pending-proposal-capacity" + create := createRequest(sessionID) + create.Features = append(create.Features, protocol.FeatureArbitration) + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + for index := 0; index < 64; index++ { + request := proposeRequest( + sessionID, + fmt.Sprintf("propose.pending-capacity.%02d", index), + 0, + nil, + ) + if _, _, err := engine.Propose(context.Background(), request); err != nil { + t.Fatalf("proposal %d: %v", index, err) + } + } + overflow := proposeRequest(sessionID, "propose.pending-capacity.overflow", 0, nil) + if _, _, err := engine.Propose(context.Background(), overflow); !errors.Is(err, rinruntime.ErrConflict) || + rinruntime.ErrorCode(err) != "proposal_capacity" { + t.Fatalf("65th pending proposal should fail closed: %v", err) + } + state, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if len(state.Proposals) != 64 { + t.Fatalf("pending proposal count = %d, want 64", len(state.Proposals)) + } + snapshot, err := engine.Snapshot(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if err := rinruntime.ValidateSnapshot(snapshot); err != nil { + t.Fatalf("capacity-bounded proposal state must remain restorable: %v", err) + } +} + type invalidPolicy struct{} func (invalidPolicy) Propose(context.Context, rinruntime.PolicyContext) (rinruntime.ProposalDraft, error) { @@ -253,6 +744,112 @@ func (p blockingPolicy) Propose(ctx context.Context, input rinruntime.PolicyCont } } +type firstCallBlockingPolicy struct { + started chan struct{} + release chan struct{} + mu sync.Mutex + calls int +} + +func (p *firstCallBlockingPolicy) Propose(ctx context.Context, input rinruntime.PolicyContext) (rinruntime.ProposalDraft, error) { + p.mu.Lock() + p.calls++ + call := p.calls + p.mu.Unlock() + if call == 1 { + close(p.started) + select { + case <-p.release: + case <-ctx.Done(): + return rinruntime.ProposalDraft{}, ctx.Err() + } + } + return rinruntime.ProposalDraft{ + ActionID: "talk", + Stance: "engage", + Summary: "Mira proposes a reply.", + Rationale: "Allowed by the game.", + }, nil +} + +func TestConcurrentIdempotentProposeReportsEvictedProposal(t *testing.T) { + const sessionID = "session.concurrent-evicted-proposal" + policy := &firstCallBlockingPolicy{ + started: make(chan struct{}), + release: make(chan struct{}), + } + defer func() { + select { + case <-policy.release: + default: + close(policy.release) + } + }() + engine := newEngine(t, store.NewMemory(), policy) + if _, err := engine.CreateSession(createRequest(sessionID)); err != nil { + t.Fatal(err) + } + request := proposeRequest(sessionID, "propose.concurrent-evicted", 0, nil) + firstResult := make(chan error, 1) + go func() { + _, _, err := engine.Propose(context.Background(), request) + firstResult <- err + }() + select { + case <-policy.started: + case <-time.After(time.Second): + t.Fatal("first policy call did not start") + } + + proposal, duplicate, err := engine.Propose(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if duplicate { + t.Fatal("second call unexpectedly reported a duplicate") + } + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.concurrent-evicted", + ProposalID: proposal.ID, + EventID: "event.concurrent-evicted", + Tick: 0, + Accepted: true, + Outcome: "The game applied the reply.", + }); err != nil { + t.Fatal(err) + } + for index := 0; index < 64; index++ { + next := proposeRequest( + sessionID, + fmt.Sprintf("propose.after-eviction.%02d", index), + 5, + nil, + ) + if _, _, err := engine.Propose(context.Background(), next); err != nil { + t.Fatalf("retained proposal %d: %v", index, err) + } + } + state, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if _, exists := state.Proposals[proposal.ID]; exists { + t.Fatal("resolved proposal was not evicted before the blocked retry resumed") + } + + close(policy.release) + select { + case err := <-firstResult: + if !errors.Is(err, rinruntime.ErrNotFound) || rinruntime.ErrorCode(err) != "proposal_missing" { + t.Fatalf("blocked idempotent call should report its evicted proposal, got %v", err) + } + case <-time.After(time.Second): + t.Fatal("blocked idempotent call did not return") + } +} + func TestPolicyWaitDoesNotBlockObservations(t *testing.T) { policy := blockingPolicy{started: make(chan struct{}), release: make(chan struct{})} engine := newEngine(t, store.NewMemory(), policy) @@ -307,6 +904,9 @@ func createRequest(sessionID string) protocol.CreateSessionRequest { ContentHash: "sha256-demo", }, Seed: 42, + Features: []string{ + protocol.FeatureOutcomeReporting, + }, Actors: []protocol.ActorSeed{{ ID: "npc.mira", Kind: "npc", diff --git a/runtime/living_cognition_test.go b/runtime/living_cognition_test.go index 865a4c9..361dfb8 100644 --- a/runtime/living_cognition_test.go +++ b/runtime/living_cognition_test.go @@ -16,7 +16,7 @@ func TestLivingMemoryArchivesAndReplaysDeterministically(t *testing.T) { eventStore := store.NewMemory() engine := newEngine(t, eventStore, policy.Deterministic{}) create := createRequest("session.archive") - create.Features = []string{protocol.FeatureMemoryArchive} + create.Features = append(create.Features, protocol.FeatureMemoryArchive) if _, err := engine.CreateSession(create); err != nil { t.Fatal(err) } @@ -86,7 +86,7 @@ func TestLivingMemoryArchivesAndReplaysDeterministically(t *testing.T) { func TestBeliefConflictsRemainActorLocal(t *testing.T) { engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) create := createRequest("session.beliefs") - create.Features = []string{protocol.FeatureBeliefConflicts} + create.Features = append(create.Features, protocol.FeatureBeliefConflicts) second := create.Actors[0] second.ID = "npc.oren" second.DisplayName = "Oren" @@ -118,7 +118,7 @@ func TestBeliefConflictsRemainActorLocal(t *testing.T) { } mira := state.Actors["npc.mira"] set := mira.BeliefSets["relic:location"] - if !set.Conflicted || len(set.Claims) != 2 || mira.Beliefs["relic:location"].Object != "harbor" { + if !set.Conflicted || len(set.Claims) != 2 || mira.Beliefs["relic:location"].Object != "tower" { t.Fatalf("unexpected conflicting belief state: set=%+v selected=%+v", set, mira.Beliefs["relic:location"]) } if len(state.Actors["npc.oren"].Beliefs) != 0 || len(state.Actors["npc.oren"].BeliefSets) != 0 { diff --git a/runtime/living_world_test.go b/runtime/living_world_test.go index ef38046..fd9f42d 100644 --- a/runtime/living_world_test.go +++ b/runtime/living_world_test.go @@ -15,7 +15,7 @@ import ( func TestCandidateGoalIsAdoptedOnlyAfterAcceptedCommit(t *testing.T) { engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) create := createRequest("session.goals") - create.Features = []string{protocol.FeatureGoalCandidates} + create.Features = append(create.Features, protocol.FeatureGoalCandidates) if _, err := engine.CreateSession(create); err != nil { t.Fatal(err) } @@ -65,7 +65,7 @@ func TestCandidateGoalIsAdoptedOnlyAfterAcceptedCommit(t *testing.T) { func TestDormantActorIsExcludedUntilGameWakesIt(t *testing.T) { engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) create := createRequest("session.activity") - create.Features = []string{protocol.FeatureActorActivity} + create.Features = append(create.Features, protocol.FeatureActorActivity) if _, err := engine.CreateSession(create); err != nil { t.Fatal(err) } @@ -161,14 +161,18 @@ func TestArbitrationIsDeterministicAndBatchCommitIsAtomic(t *testing.T) { if state.WorldRevision != 2 || state.Proposals[mira.ID].Status != "accepted" || state.Proposals[oren.ID].Status != "rejected" { t.Fatalf("unexpected post-batch state: %+v", state) } - if _, err := engine.Snapshot(sessionRequest(create.SessionID)); err != nil { + snapshot, err := engine.Snapshot(sessionRequest(create.SessionID)) + if err != nil { t.Fatalf("coordinated world snapshot should validate: %v", err) } + if err := rinruntime.ValidateSnapshot(snapshot); err != nil { + t.Fatalf("coordinated world snapshot is not restorable: %v", err) + } } -func TestBatchCommitRejectsStaleWorldWithoutPartialMutation(t *testing.T) { +func TestBatchCommitReportsOutcomeAfterWorldAdvances(t *testing.T) { engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) - create := twoActorWorldRequest("session.batch-stale") + create := twoActorWorldRequest("session.batch-late") if _, err := engine.CreateSession(create); err != nil { t.Fatal(err) } @@ -176,25 +180,69 @@ func TestBatchCommitRejectsStaleWorldWithoutPartialMutation(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := engine.Observe(observeRequest(create.SessionID, "observe.change", "event.change", 0)); err != nil { + if _, err := engine.Observe(observeRequest(create.SessionID, "observe.change", "event.change", 5)); err != nil { t.Fatal(err) } - _, err = engine.CommitBatch(protocol.BatchCommitRequest{ - ProtocolVersion: protocol.Version, SessionID: create.SessionID, RequestID: "commit.stale-batch", Tick: 0, - Items: []protocol.CommitItem{{ProposalID: proposal.ID, EventID: "event.should-not-commit", Accepted: true, Outcome: "Should not happen."}}, + result, err := engine.CommitBatch(protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, SessionID: create.SessionID, RequestID: "commit.late-batch", Tick: 0, + Items: []protocol.CommitItem{{ProposalID: proposal.ID, EventID: "event.late-outcome", Accepted: true, Outcome: "The game already applied this outcome."}}, }) - if !errors.Is(err, rinruntime.ErrStale) { - t.Fatalf("expected stale batch rejection, got %v", err) + if err != nil { + t.Fatalf("late batch outcome should be recorded: %v", err) } state, _ := engine.State(sessionRequest(create.SessionID)) - if state.Proposals[proposal.ID].Status != "pending" || len(state.Actors["npc.mira"].RecentActions) != 0 { - t.Fatalf("failed batch partially mutated state: %+v", state) + if result.Revision != 4 || + state.Tick != 5 || + state.Proposals[proposal.ID].Status != "accepted" || + len(state.Actors["npc.mira"].RecentActions) != 1 { + t.Fatalf("late batch outcome was not applied: result=%+v state=%+v", result, state) + } + snapshot, err := engine.Snapshot(sessionRequest(create.SessionID)) + if err != nil { + t.Fatalf("late batch state should remain snapshot-compatible: %v", err) + } + if err := rinruntime.ValidateSnapshot(snapshot); err != nil { + t.Fatalf("late batch snapshot is not restorable: %v", err) + } +} + +func TestBatchCommitRejectsMixedProposalBasesAtomically(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + create := twoActorWorldRequest("session.batch-mixed-base") + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + mira, _, err := engine.Propose(context.Background(), targetedProposalRequest(create.SessionID, "propose.base-one", "npc.mira")) + if err != nil { + t.Fatal(err) + } + if _, err := engine.Observe(observeRequest(create.SessionID, "observe.advance", "event.advance", 0)); err != nil { + t.Fatal(err) + } + oren, _, err := engine.Propose(context.Background(), targetedProposalRequest(create.SessionID, "propose.base-two", "npc.oren")) + if err != nil { + t.Fatal(err) + } + before, _ := engine.State(sessionRequest(create.SessionID)) + _, err = engine.CommitBatch(protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, SessionID: create.SessionID, RequestID: "commit.mixed-base", Tick: 0, + Items: []protocol.CommitItem{ + {ProposalID: mira.ID, EventID: "event.mira.mixed", Accepted: true, Outcome: "Mira outcome."}, + {ProposalID: oren.ID, EventID: "event.oren.mixed", Accepted: true, Outcome: "Oren outcome."}, + }, + }) + if !errors.Is(err, rinruntime.ErrConflict) || rinruntime.ErrorCode(err) != "proposal_base_mismatch" { + t.Fatalf("expected proposal_base_mismatch, got %v", err) + } + after, _ := engine.State(sessionRequest(create.SessionID)) + if !reflect.DeepEqual(before, after) { + t.Fatalf("mixed-base batch partially mutated state: before=%+v after=%+v", before, after) } } func twoActorWorldRequest(sessionID string) protocol.CreateSessionRequest { create := createRequest(sessionID) - create.Features = []string{protocol.FeatureArbitration} + create.Features = append(create.Features, protocol.FeatureArbitration) oren := create.Actors[0] oren.ID = "npc.oren" oren.DisplayName = "Oren" diff --git a/runtime/outcome_merge_test.go b/runtime/outcome_merge_test.go new file mode 100644 index 0000000..762f868 --- /dev/null +++ b/runtime/outcome_merge_test.go @@ -0,0 +1,824 @@ +package runtime_test + +import ( + "context" + "errors" + "math" + "reflect" + "testing" + + "github.com/sunrioa/rin/policy" + "github.com/sunrioa/rin/protocol" + rinruntime "github.com/sunrioa/rin/runtime" + "github.com/sunrioa/rin/store" +) + +func TestSessionsWithoutOutcomeFeaturePreserveLegacyReplaySemantics(t *testing.T) { + eventStore := store.NewMemory() + engine := newEngine(t, eventStore, policy.Deterministic{}) + const sessionID = "session.legacy-outcome-semantics" + create := createRequest(sessionID) + create.Features = []string{protocol.FeatureBeliefConflicts} + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + first, _, err := engine.Propose( + context.Background(), + proposeRequest(sessionID, "propose.legacy-first", 0, nil), + ) + if err != nil { + t.Fatal(err) + } + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.legacy-first", + ProposalID: first.ID, + EventID: "event.legacy-first", + Tick: 0, + Accepted: true, + Outcome: "Legacy first action.", + GoalUpdates: []protocol.GoalUpdate{{ + GoalID: "goal.connect", ProgressDelta: -5, + }}, + }); err != nil { + t.Fatal(err) + } + second, _, err := engine.Propose( + context.Background(), + proposeRequest(sessionID, "propose.legacy-second", 5, nil), + ) + if err != nil { + t.Fatal(err) + } + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.legacy-second", + ProposalID: second.ID, + EventID: "event.legacy-second", + Tick: 5, + Accepted: true, + Outcome: "Legacy second action.", + GoalUpdates: []protocol.GoalUpdate{{ + GoalID: "goal.connect", ProgressDelta: 5, + }}, + }); err != nil { + t.Fatal(err) + } + older := observeRequest(sessionID, "observe.legacy-older", "event.legacy-older", 6) + older.Facts = []protocol.Fact{{ + SubjectID: "relic", Predicate: "location", Object: "harbor", Confidence: 90, + }} + if _, err := engine.Observe(older); err != nil { + t.Fatal(err) + } + newer := observeRequest(sessionID, "observe.legacy-newer", "event.legacy-newer", 7) + newer.Facts = []protocol.Fact{{ + SubjectID: "relic", Predicate: "location", Object: "tower", Confidence: 60, + }} + if _, err := engine.Observe(newer); err != nil { + t.Fatal(err) + } + + before, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + goal, found := findGoal(before.Actors["npc.mira"], "goal.connect") + if !found || + goal.Progress != 3 || + goal.ProgressAccumulator != 0 || + goal.UpdatedTick != 0 || + goal.StatusExplicit || + before.Actors["npc.mira"].Beliefs["relic:location"].Object != "harbor" || + before.Actors["npc.mira"].Beliefs["relic:location"].ObservedTick != 0 || + before.Proposals[first.ID].OutcomeEventID != "" || + before.Proposals[second.ID].OutcomeEventID != "" { + t.Fatalf("pre-feature session did not retain legacy state semantics: goal=%+v state=%+v", goal, before) + } + reopened := newEngine(t, eventStore, policy.Deterministic{}) + after, err := reopened.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Fatalf("legacy event-log replay diverged:\nbefore=%+v\nafter=%+v", before, after) + } +} + +func TestLateCommitMergesDerivedStateByOccurrenceTick(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + const sessionID = "session.late-merge" + create := createRequest(sessionID) + create.Features = append(create.Features, protocol.FeatureBeliefConflicts) + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + if _, err := engine.Observe(observeRequest(sessionID, "observe.seed", "event.seed", 0)); err != nil { + t.Fatal(err) + } + + older, _, err := engine.Propose(context.Background(), proposeRequest(sessionID, "propose.older", 0, nil)) + if err != nil { + t.Fatal(err) + } + newer, _, err := engine.Propose(context.Background(), proposeRequest(sessionID, "propose.newer", 10, nil)) + if err != nil { + t.Fatal(err) + } + + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.newer", + ProposalID: newer.ID, + EventID: "event.newer", + Tick: 10, + Accepted: true, + Outcome: "The newer action happened.", + Facts: []protocol.Fact{{ + SubjectID: "door", Predicate: "state", Object: "open", Confidence: 80, + }}, + GoalUpdates: []protocol.GoalUpdate{{GoalID: "goal.connect", Status: "released"}}, + }); err != nil { + t.Fatal(err) + } + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.older", + ProposalID: older.ID, + EventID: "event.older", + Tick: 0, + Accepted: true, + Outcome: "The older action report arrived late.", + Facts: []protocol.Fact{{ + SubjectID: "door", Predicate: "state", Object: "closed", Confidence: 100, + }}, + GoalUpdates: []protocol.GoalUpdate{{GoalID: "goal.connect", Status: "active"}}, + }); err != nil { + t.Fatalf("late outcome should merge without regressing newer state: %v", err) + } + + state, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + actor := state.Actors["npc.mira"] + if state.Tick != 10 || actor.NextThinkTick != 15 { + t.Fatalf("late outcome regressed scheduler state: tick=%d next=%d", state.Tick, actor.NextThinkTick) + } + if len(actor.RecentActions) != 2 || + actor.RecentActions[0].ID != older.ID || + actor.RecentActions[1].ID != newer.ID { + t.Fatalf("recent actions are not occurrence ordered: %+v", actor.RecentActions) + } + if got := actor.Beliefs["door:state"]; got.Object != "open" || got.ObservedTick != 10 { + t.Fatalf("late fact replaced a newer game fact: %+v", got) + } + if set := actor.BeliefSets["door:state"]; len(set.Claims) != 2 || + set.SelectedSourceEventID != "event.newer" { + t.Fatalf("conflict projection did not prefer the newer occurrence: %+v", set) + } + goal, found := findGoal(actor, "goal.connect") + if !found || + goal.Status != "released" || + goal.UpdatedTick != 10 || + goal.StatusUpdatedTick != 10 || + goal.StatusSourceEventID != "event.newer" || + goal.Progress != 2 { + t.Fatalf("late goal update regressed status or lost commutative progress: %+v", goal) + } + if len(older.RecalledMemoryIDs) == 0 { + t.Fatal("test setup expected both proposals to recall the seed memory") + } + for _, memory := range actor.Memories { + if memory.ID == older.RecalledMemoryIDs[0] && + (memory.RecallCount != 2 || memory.LastRecalledTick != 10) { + t.Fatalf("late recall regressed recall metadata: %+v", memory) + } + } + if len(actor.Memories) != 3 || + actor.Memories[0].Tick > actor.Memories[1].Tick || + actor.Memories[1].Tick > actor.Memories[2].Tick { + t.Fatalf("memories are not occurrence ordered: %+v", actor.Memories) + } + snapshot, err := engine.Snapshot(sessionRequest(sessionID)) + if err != nil { + t.Fatalf("late-merged state must remain snapshot-compatible: %v", err) + } + if err := rinruntime.ValidateSnapshot(snapshot); err != nil { + t.Fatalf("late-merged snapshot is not restorable: %v", err) + } +} + +func TestBeliefConflictCapacityKeepsNewestOccurrences(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + const sessionID = "session.belief-occurrence-capacity" + create := createRequest(sessionID) + create.Features = append(create.Features, protocol.FeatureBeliefConflicts) + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + for tick := int64(10); tick >= 1; tick-- { + request := observeRequest( + sessionID, + "observe.belief-"+string(rune('a'+tick)), + "event.belief-"+string(rune('a'+tick)), + tick, + ) + request.Facts = []protocol.Fact{{ + SubjectID: "gate", + Predicate: "state", + Object: "state-" + string(rune('a'+tick)), + Confidence: func() int { + if tick <= 2 { + return 100 + } + return 50 + }(), + }} + if _, err := engine.Observe(request); err != nil { + t.Fatal(err) + } + } + state, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + set := state.Actors["npc.mira"].BeliefSets["gate:state"] + if len(set.Claims) != 8 { + t.Fatalf("belief claim count = %d, want 8: %+v", len(set.Claims), set) + } + minimumTick := int64(10) + for _, claim := range set.Claims { + if claim.Fact.ObservedTick < minimumTick { + minimumTick = claim.Fact.ObservedTick + } + if claim.Fact.ObservedTick <= 2 { + t.Fatalf("old high-confidence claim survived occurrence-first trimming: %+v", claim) + } + } + selected := state.Actors["npc.mira"].Beliefs["gate:state"] + if minimumTick != 3 || selected.ObservedTick != 10 { + t.Fatalf("belief capacity did not retain/select newest occurrences: set=%+v selected=%+v", set, selected) + } +} + +func TestLateBatchCommitMergesByOccurrenceTick(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + create := twoActorWorldRequest("session.late-batch-merge") + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + + oldMira, _, err := engine.Propose(context.Background(), targetedProposalRequest(create.SessionID, "propose.old-mira", "npc.mira")) + if err != nil { + t.Fatal(err) + } + oldOren, _, err := engine.Propose(context.Background(), targetedProposalRequest(create.SessionID, "propose.old-oren", "npc.oren")) + if err != nil { + t.Fatal(err) + } + advance := observeRequest(create.SessionID, "observe.advance-batch", "event.advance-batch", 10) + advance.ObserverIDs = []string{"npc.mira", "npc.oren"} + if _, err := engine.Observe(advance); err != nil { + t.Fatal(err) + } + newMiraRequest := targetedProposalRequest(create.SessionID, "propose.new-mira", "npc.mira") + newMiraRequest.Tick = 10 + newMira, _, err := engine.Propose(context.Background(), newMiraRequest) + if err != nil { + t.Fatal(err) + } + newOrenRequest := targetedProposalRequest(create.SessionID, "propose.new-oren", "npc.oren") + newOrenRequest.Tick = 10 + newOren, _, err := engine.Propose(context.Background(), newOrenRequest) + if err != nil { + t.Fatal(err) + } + + newFacts := []protocol.Fact{{SubjectID: "camera", Predicate: "state", Object: "repaired", Confidence: 80}} + if _, err := engine.CommitBatch(protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: create.SessionID, + RequestID: "commit.batch-new", + Tick: 10, + Items: []protocol.CommitItem{ + {ProposalID: newMira.ID, EventID: "event.batch-new-mira", Accepted: true, Outcome: "Mira repaired it.", Facts: newFacts}, + {ProposalID: newOren.ID, EventID: "event.batch-new-oren", Accepted: true, Outcome: "Oren documented it.", Facts: newFacts}, + }, + }); err != nil { + t.Fatal(err) + } + oldFacts := []protocol.Fact{{SubjectID: "camera", Predicate: "state", Object: "damaged", Confidence: 100}} + if _, err := engine.CommitBatch(protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: create.SessionID, + RequestID: "commit.batch-old", + Tick: 0, + Items: []protocol.CommitItem{ + {ProposalID: oldMira.ID, EventID: "event.batch-old-mira", Accepted: true, Outcome: "Mira first inspected it.", Facts: oldFacts}, + {ProposalID: oldOren.ID, EventID: "event.batch-old-oren", Accepted: true, Outcome: "Oren first inspected it.", Facts: oldFacts}, + }, + }); err != nil { + t.Fatalf("late batch outcome should be recorded: %v", err) + } + + state, err := engine.State(sessionRequest(create.SessionID)) + if err != nil { + t.Fatal(err) + } + if state.Tick != 10 { + t.Fatalf("late batch regressed session tick to %d", state.Tick) + } + for actorID, ids := range map[string][2]string{ + "npc.mira": {oldMira.ID, newMira.ID}, + "npc.oren": {oldOren.ID, newOren.ID}, + } { + actor := state.Actors[actorID] + if actor.NextThinkTick != 15 { + t.Fatalf("%s scheduler regressed to %d", actorID, actor.NextThinkTick) + } + if len(actor.RecentActions) != 2 || + actor.RecentActions[0].ID != ids[0] || + actor.RecentActions[1].ID != ids[1] { + t.Fatalf("%s actions are not occurrence ordered: %+v", actorID, actor.RecentActions) + } + if got := actor.Beliefs["camera:state"]; got.Object != "repaired" || got.ObservedTick != 10 { + t.Fatalf("%s late fact replaced newer state: %+v", actorID, got) + } + } + snapshot, err := engine.Snapshot(sessionRequest(create.SessionID)) + if err != nil { + t.Fatalf("late batch state must remain snapshot-compatible: %v", err) + } + if err := rinruntime.ValidateSnapshot(snapshot); err != nil { + t.Fatalf("late batch snapshot is not restorable: %v", err) + } +} + +func TestGoalProgressDeltasAreIndependentOfOutcomeArrivalOrder(t *testing.T) { + type outcomeSpec struct { + name string + tick int64 + delta int + } + older := outcomeSpec{name: "older", tick: 0, delta: -3} + newer := outcomeSpec{name: "newer", tick: 10, delta: 2} + orders := [][]outcomeSpec{{newer, older}, {older, newer}} + + for index, order := range orders { + sessionID := "session.goal-delta-order-" + string(rune('a'+index)) + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + create := createRequest(sessionID) + create.Actors[0].Goals[0].Progress = 1 + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + + proposals := make(map[string]protocol.ActionProposal, len(order)) + for _, spec := range []outcomeSpec{older, newer} { + proposal, _, err := engine.Propose( + context.Background(), + proposeRequest(sessionID, "propose."+spec.name, spec.tick, nil), + ) + if err != nil { + t.Fatal(err) + } + proposals[spec.name] = proposal + } + for _, spec := range order { + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit." + spec.name, + ProposalID: proposals[spec.name].ID, + EventID: "event." + spec.name, + Tick: spec.tick, + Accepted: true, + Outcome: "The game applied the " + spec.name + " action.", + GoalUpdates: []protocol.GoalUpdate{{ + GoalID: "goal.connect", ProgressDelta: spec.delta, + }}, + }); err != nil { + t.Fatal(err) + } + } + + state, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + goal, found := findGoal(state.Actors["npc.mira"], "goal.connect") + if !found || + goal.Progress != 2 || + goal.ProgressAccumulator != 2 || + goal.Status != "active" || + goal.StatusExplicit { + t.Fatalf("arrival order %d produced order-dependent progress: %+v", index, goal) + } + } +} + +func TestGoalStatusOrderingIsIndependentFromProgressOnlyUpdates(t *testing.T) { + type outcomeSpec struct { + name string + tick int64 + status string + delta int + } + early := outcomeSpec{name: "early-status", tick: 10, status: "released"} + middle := outcomeSpec{name: "middle-status", tick: 15, status: "completed"} + late := outcomeSpec{name: "late-progress", tick: 20, delta: 2} + orders := [][]outcomeSpec{ + {early, middle, late}, + {early, late, middle}, + {middle, early, late}, + {middle, late, early}, + {late, early, middle}, + {late, middle, early}, + } + + for index, order := range orders { + sessionID := "session.goal-status-order-" + string(rune('a'+index)) + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + create := createRequest(sessionID) + create.Actors[0].Goals[0].TargetProgress = 100 + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + + proposals := make(map[string]protocol.ActionProposal, len(order)) + for _, spec := range []outcomeSpec{early, middle, late} { + proposal, _, err := engine.Propose( + context.Background(), + proposeRequest(sessionID, "propose."+spec.name, spec.tick, nil), + ) + if err != nil { + t.Fatal(err) + } + proposals[spec.name] = proposal + } + for _, spec := range order { + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit." + spec.name, + ProposalID: proposals[spec.name].ID, + EventID: "event." + spec.name, + Tick: spec.tick, + Accepted: true, + Outcome: "The game applied " + spec.name + ".", + GoalUpdates: []protocol.GoalUpdate{{ + GoalID: "goal.connect", + ProgressDelta: spec.delta, + Status: spec.status, + }}, + }); err != nil { + t.Fatal(err) + } + } + + state, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + goal, found := findGoal(state.Actors["npc.mira"], "goal.connect") + if !found || + goal.Status != "completed" || + !goal.StatusExplicit || + goal.StatusUpdatedTick != 15 || + goal.StatusSourceEventID != "event.middle-status" || + goal.UpdatedTick != 20 || + goal.Progress != 5 || + goal.ProgressAccumulator != 5 { + t.Fatalf("arrival order %d produced order-dependent goal status: %+v", index, goal) + } + } +} + +func TestGoalStatusSameTickUsesStableEventIDTieBreak(t *testing.T) { + type statusSpec struct { + name string + status string + } + alpha := statusSpec{name: "alpha", status: "released"} + zulu := statusSpec{name: "zulu", status: "completed"} + for index, order := range [][]statusSpec{{alpha, zulu}, {zulu, alpha}} { + sessionID := "session.goal-status-tie-" + string(rune('a'+index)) + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + if _, err := engine.CreateSession(createRequest(sessionID)); err != nil { + t.Fatal(err) + } + proposals := make(map[string]protocol.ActionProposal, 2) + for _, spec := range []statusSpec{alpha, zulu} { + proposal, _, err := engine.Propose( + context.Background(), + proposeRequest(sessionID, "propose.tie-"+spec.name, 10, nil), + ) + if err != nil { + t.Fatal(err) + } + proposals[spec.name] = proposal + } + for _, spec := range order { + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.tie-" + spec.name, + ProposalID: proposals[spec.name].ID, + EventID: "event.tie-" + spec.name, + Tick: 10, + Accepted: true, + Outcome: "The game applied " + spec.name + ".", + GoalUpdates: []protocol.GoalUpdate{{ + GoalID: "goal.connect", Status: spec.status, + }}, + }); err != nil { + t.Fatal(err) + } + } + state, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + goal, found := findGoal(state.Actors["npc.mira"], "goal.connect") + if !found || + goal.Status != "completed" || + goal.StatusSourceEventID != "event.tie-zulu" || + goal.StatusUpdatedTick != 10 { + t.Fatalf("same-tick arrival order %d produced unstable status: %+v", index, goal) + } + } +} + +func TestTickZeroAutomaticGoalStatusDoesNotBecomeExplicitMidCommit(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + const sessionID = "session.goal-tick-zero" + create := createRequest(sessionID) + create.Actors[0].Goals[0].TargetProgress = 1 + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + proposal, _, err := engine.Propose( + context.Background(), + proposeRequest(sessionID, "propose.goal-tick-zero", 0, nil), + ) + if err != nil { + t.Fatal(err) + } + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.goal-tick-zero", + ProposalID: proposal.ID, + EventID: "event.goal-tick-zero", + Tick: 0, + Accepted: true, + Outcome: "The game applied and then reversed the progress in one outcome.", + GoalUpdates: []protocol.GoalUpdate{{ + GoalID: "goal.connect", ProgressDelta: -1, + }}, + }); err != nil { + t.Fatal(err) + } + state, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + goal, found := findGoal(state.Actors["npc.mira"], "goal.connect") + if !found || + goal.Progress != 0 || + goal.ProgressAccumulator != 0 || + goal.Status != "active" || + goal.StatusExplicit || + goal.StatusUpdatedTick != 0 || + goal.StatusSourceEventID != "" { + t.Fatalf("tick-zero automatic status was frozen as explicit: %+v", goal) + } +} + +func TestOutcomeEventIDsAreUniqueAcrossMutationKinds(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + const sessionID = "session.event-id-unique" + if _, err := engine.CreateSession(createRequest(sessionID)); err != nil { + t.Fatal(err) + } + if _, err := engine.Observe(observeRequest(sessionID, "observe.shared", "event.shared", 0)); err != nil { + t.Fatal(err) + } + proposal, _, err := engine.Propose(context.Background(), proposeRequest(sessionID, "propose.shared", 0, nil)) + if err != nil { + t.Fatal(err) + } + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.shared", + ProposalID: proposal.ID, + EventID: "event.shared", + Tick: 0, + Accepted: true, + Outcome: "This must not be recorded.", + }); !errors.Is(err, rinruntime.ErrConflict) || rinruntime.ErrorCode(err) != "event_exists" { + t.Fatalf("commit reused an observation event id: %v", err) + } + + rejected, _, err := engine.Propose(context.Background(), proposeRequest(sessionID, "propose.rejected-id", 0, nil)) + if err != nil { + t.Fatal(err) + } + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.rejected-id", + ProposalID: rejected.ID, + EventID: "event.rejected-id", + Tick: 0, + Accepted: false, + Outcome: "The game rejected it.", + }); err != nil { + t.Fatal(err) + } + duplicateObservation := observeRequest( + sessionID, + "observe.reuse-rejected-id", + "event.rejected-id", + 0, + ) + if _, err := engine.Observe(duplicateObservation); !errors.Is(err, rinruntime.ErrConflict) || + rinruntime.ErrorCode(err) != "event_exists" { + t.Fatalf("observation reused a rejected outcome event id: %v", err) + } + next, _, err := engine.Propose(context.Background(), proposeRequest(sessionID, "propose.after-rejection", 0, nil)) + if err != nil { + t.Fatal(err) + } + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.reuse-rejected-id", + ProposalID: next.ID, + EventID: "event.rejected-id", + Tick: 0, + Accepted: true, + Outcome: "This must not be recorded.", + }); !errors.Is(err, rinruntime.ErrConflict) || rinruntime.ErrorCode(err) != "event_exists" { + t.Fatalf("accepted commit reused a rejected outcome event id: %v", err) + } +} + +func TestBatchOutcomeEventIDsAreUniqueWithinBatchAndAcrossKinds(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + create := twoActorWorldRequest("session.batch-event-id-unique") + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + observation := observeRequest(create.SessionID, "observe.batch-shared", "event.batch-shared", 0) + observation.ObserverIDs = []string{"npc.mira", "npc.oren"} + if _, err := engine.Observe(observation); err != nil { + t.Fatal(err) + } + mira, _, err := engine.Propose(context.Background(), targetedProposalRequest(create.SessionID, "propose.batch-id-mira", "npc.mira")) + if err != nil { + t.Fatal(err) + } + oren, _, err := engine.Propose(context.Background(), targetedProposalRequest(create.SessionID, "propose.batch-id-oren", "npc.oren")) + if err != nil { + t.Fatal(err) + } + before, err := engine.State(sessionRequest(create.SessionID)) + if err != nil { + t.Fatal(err) + } + if _, err := engine.CommitBatch(protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: create.SessionID, + RequestID: "commit.batch-cross-kind-id", + Tick: 0, + Items: []protocol.CommitItem{ + {ProposalID: mira.ID, EventID: "event.batch-shared", Accepted: true, Outcome: "Must not commit."}, + {ProposalID: oren.ID, EventID: "event.batch-other", Accepted: true, Outcome: "Must not commit."}, + }, + }); !errors.Is(err, rinruntime.ErrConflict) || rinruntime.ErrorCode(err) != "event_exists" { + t.Fatalf("batch reused an observation event id: %v", err) + } + if _, err := engine.CommitBatch(protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: create.SessionID, + RequestID: "commit.batch-duplicate-id", + Tick: 0, + Items: []protocol.CommitItem{ + {ProposalID: mira.ID, EventID: "event.batch-duplicate", Accepted: true, Outcome: "Must not commit."}, + {ProposalID: oren.ID, EventID: "event.batch-duplicate", Accepted: true, Outcome: "Must not commit."}, + }, + }); rinruntime.ErrorCode(err) != "invalid_request" { + t.Fatalf("batch accepted duplicate item event ids: %v", err) + } + afterFailures, err := engine.State(sessionRequest(create.SessionID)) + if err != nil { + t.Fatal(err) + } + if afterFailures.Revision != before.Revision || + afterFailures.Proposals[mira.ID].Status != "pending" || + afterFailures.Proposals[oren.ID].Status != "pending" { + t.Fatalf("invalid batch mutated state: before=%+v after=%+v", before, afterFailures) + } + + if _, err := engine.CommitBatch(protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: create.SessionID, + RequestID: "commit.batch-record-ids", + Tick: 0, + Items: []protocol.CommitItem{ + {ProposalID: mira.ID, EventID: "event.batch-rejected", Accepted: false, Outcome: "The game rejected it."}, + {ProposalID: oren.ID, EventID: "event.batch-accepted", Accepted: true, Outcome: "The game applied it."}, + }, + }); err != nil { + t.Fatal(err) + } + next, _, err := engine.Propose(context.Background(), targetedProposalRequest(create.SessionID, "propose.after-batch-id", "npc.mira")) + if err != nil { + t.Fatal(err) + } + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: create.SessionID, + RequestID: "commit.reuse-batch-rejected", + ProposalID: next.ID, + EventID: "event.batch-rejected", + Tick: 0, + Accepted: true, + Outcome: "Must not reuse a rejected batch event id.", + }); !errors.Is(err, rinruntime.ErrConflict) || rinruntime.ErrorCode(err) != "event_exists" { + t.Fatalf("single commit reused a rejected batch event id: %v", err) + } +} + +func TestCommitRejectsNextThinkTickOverflow(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + const sessionID = "session.tick-overflow" + if _, err := engine.CreateSession(createRequest(sessionID)); err != nil { + t.Fatal(err) + } + proposal, _, err := engine.Propose(context.Background(), proposeRequest(sessionID, "propose.max-tick", math.MaxInt64, nil)) + if err != nil { + t.Fatal(err) + } + if _, err := engine.Commit(protocol.CommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: sessionID, + RequestID: "commit.max-tick", + ProposalID: proposal.ID, + EventID: "event.max-tick", + Tick: math.MaxInt64, + Accepted: true, + Outcome: "This must not overflow scheduling state.", + }); !errors.Is(err, rinruntime.ErrConflict) || rinruntime.ErrorCode(err) != "tick_overflow" { + t.Fatalf("expected tick_overflow, got %v", err) + } + state, err := engine.State(sessionRequest(sessionID)) + if err != nil { + t.Fatal(err) + } + if state.Proposals[proposal.ID].Status != "pending" || state.Revision != 2 { + t.Fatalf("overflowing commit mutated state: %+v", state) + } +} + +func TestBatchCommitRejectsNextThinkTickOverflow(t *testing.T) { + engine := newEngine(t, store.NewMemory(), policy.Deterministic{}) + create := twoActorWorldRequest("session.batch-tick-overflow") + if _, err := engine.CreateSession(create); err != nil { + t.Fatal(err) + } + request := targetedProposalRequest(create.SessionID, "propose.batch-max-tick", "npc.mira") + request.Tick = math.MaxInt64 + proposal, _, err := engine.Propose(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if _, err := engine.CommitBatch(protocol.BatchCommitRequest{ + ProtocolVersion: protocol.Version, + SessionID: create.SessionID, + RequestID: "commit.batch-max-tick", + Tick: math.MaxInt64, + Items: []protocol.CommitItem{{ + ProposalID: proposal.ID, + EventID: "event.batch-max-tick", + Accepted: true, + Outcome: "This must not overflow scheduling state.", + }}, + }); !errors.Is(err, rinruntime.ErrConflict) || rinruntime.ErrorCode(err) != "tick_overflow" { + t.Fatalf("expected batch tick_overflow, got %v", err) + } + state, err := engine.State(sessionRequest(create.SessionID)) + if err != nil { + t.Fatal(err) + } + if state.Proposals[proposal.ID].Status != "pending" || state.Revision != 2 { + t.Fatalf("overflowing batch commit mutated state: %+v", state) + } +} diff --git a/runtime/reducer.go b/runtime/reducer.go index 0a46312..d6581e9 100644 --- a/runtime/reducer.go +++ b/runtime/reducer.go @@ -14,6 +14,8 @@ const ( maxProposals = 64 maxReceipts = 1024 maxArbitrations = 32 + maxInt64 = int64(1<<63 - 1) + minInt64 = int64(-1 << 63) ) type createdPayload struct { @@ -25,7 +27,8 @@ type observedPayload struct { } type proposedPayload struct { - Proposal protocol.ActionProposal `json:"proposal"` + Proposal protocol.ActionProposal `json:"proposal"` + RequestHash string `json:"request_hash,omitempty"` } type committedPayload struct { @@ -96,6 +99,13 @@ func applyCreated(state protocol.SessionState, event protocol.EventRecord) (prot } actors := make(map[string]protocol.ActorState, len(request.Actors)) for _, seed := range request.Actors { + if protocol.HasFeature(request.Features, protocol.FeatureOutcomeReporting) { + seed.Goals = append([]protocol.Goal(nil), seed.Goals...) + for index := range seed.Goals { + seed.Goals[index].ProgressAccumulator = int64(seed.Goals[index].Progress) + seed.Goals[index].StatusExplicit = seed.Goals[index].Status != "active" + } + } actors[seed.ID] = protocol.ActorState{ ActorSeed: seed, Beliefs: make(map[string]protocol.Fact), @@ -126,6 +136,7 @@ func applyObserved(state *protocol.SessionState, event protocol.EventRecord) err return fmt.Errorf("%w: decode observe payload: %v", ErrCorruptLog, err) } request := payload.Request + outcomeReporting := protocol.HasFeature(state.Features, protocol.FeatureOutcomeReporting) for _, actorID := range request.ObserverIDs { actor, exists := state.Actors[actorID] if !exists { @@ -149,6 +160,9 @@ func applyObserved(state *protocol.SessionState, event protocol.EventRecord) err Importance: request.Importance, CreatedRevision: event.Sequence, }) + if outcomeReporting { + sortActorMemories(&actor) + } if protocol.HasFeature(state.Features, protocol.FeatureMemoryArchive) { if err := compactActorMemories(state.SessionID, &actor, event.Sequence); err != nil { return err @@ -156,7 +170,15 @@ func applyObserved(state *protocol.SessionState, event protocol.EventRecord) err } else if len(actor.Memories) > maxMemories { actor.Memories = append([]protocol.Memory(nil), actor.Memories[len(actor.Memories)-maxMemories:]...) } - applyFacts(&actor, request.Facts, request.EventID, event.Sequence, protocol.HasFeature(state.Features, protocol.FeatureBeliefConflicts)) + applyFacts( + &actor, + request.Facts, + request.EventID, + request.Tick, + event.Sequence, + protocol.HasFeature(state.Features, protocol.FeatureBeliefConflicts), + outcomeReporting, + ) state.Actors[actorID] = actor } if request.Tick > state.Tick { @@ -178,7 +200,12 @@ func applyProposed(state *protocol.SessionState, event protocol.EventRecord) err } state.Proposals[proposal.ID] = proposal trimProposals(state) - state.Receipts[proposal.RequestID] = protocol.RequestReceipt{Kind: EventProposed, EntityID: proposal.ID, Revision: event.Sequence} + state.Receipts[proposal.RequestID] = protocol.RequestReceipt{ + Kind: EventProposed, + EntityID: proposal.ID, + Revision: event.Sequence, + RequestHash: payload.RequestHash, + } return nil } @@ -233,20 +260,50 @@ func applyCommitItem(state *protocol.SessionState, item protocol.CommitItem, tic } else { proposal.Status = "rejected" } + outcomeReporting := protocol.HasFeature(state.Features, protocol.FeatureOutcomeReporting) + if outcomeReporting { + proposal.OutcomeEventID = item.EventID + proposal.OutcomeTick = tick + } state.Proposals[proposal.ID] = proposal if !item.Accepted { return nil } actor := state.Actors[proposal.ActorID] if proposal.ProposedGoal != nil && !goalExists(actor, proposal.ProposedGoal.ID) { - actor.Goals = append(actor.Goals, *proposal.ProposedGoal) + goal := *proposal.ProposedGoal + if outcomeReporting { + goal.UpdatedTick = tick + goal.ProgressAccumulator = int64(goal.Progress) + goal.StatusExplicit = false + goal.StatusUpdatedTick = 0 + goal.StatusSourceEventID = "" + } + actor.Goals = append(actor.Goals, goal) } actor.RecentActions = append(actor.RecentActions, proposal) + if outcomeReporting { + sort.SliceStable(actor.RecentActions, func(i, j int) bool { + if actor.RecentActions[i].OutcomeTick == actor.RecentActions[j].OutcomeTick { + if actor.RecentActions[i].OutcomeEventID != actor.RecentActions[j].OutcomeEventID { + return actor.RecentActions[i].OutcomeEventID < actor.RecentActions[j].OutcomeEventID + } + return actor.RecentActions[i].ID < actor.RecentActions[j].ID + } + return actor.RecentActions[i].OutcomeTick < actor.RecentActions[j].OutcomeTick + }) + } if len(actor.RecentActions) > maxRecentActions { actor.RecentActions = append([]protocol.ActionProposal(nil), actor.RecentActions[len(actor.RecentActions)-maxRecentActions:]...) } - actor.NextThinkTick = tick + actor.ThinkEveryTicks - markRecalled(&actor, proposal.RecalledMemoryIDs, tick) + if outcomeReporting && tick > maxInt64-actor.ThinkEveryTicks { + return fmt.Errorf("%w: commit tick overflows next think tick", ErrCorruptLog) + } + nextThinkTick := tick + actor.ThinkEveryTicks + if !outcomeReporting || nextThinkTick > actor.NextThinkTick { + actor.NextThinkTick = nextThinkTick + } + markRecalled(&actor, proposal.RecalledMemoryIDs, tick, outcomeReporting) if item.Outcome != "" { memoryID, err := hashJSON(struct { ActorID string `json:"actor_id"` @@ -260,6 +317,9 @@ func applyCommitItem(state *protocol.SessionState, item protocol.CommitItem, tic Summary: item.Outcome, Tags: append([]string(nil), item.Tags...), Importance: 3, CreatedRevision: revision, }) + if outcomeReporting { + sortActorMemories(&actor) + } if protocol.HasFeature(state.Features, protocol.FeatureMemoryArchive) { if err := compactActorMemories(state.SessionID, &actor, revision); err != nil { return err @@ -268,10 +328,29 @@ func applyCommitItem(state *protocol.SessionState, item protocol.CommitItem, tic actor.Memories = append([]protocol.Memory(nil), actor.Memories[len(actor.Memories)-maxMemories:]...) } } - applyFacts(&actor, item.Facts, item.EventID, revision, protocol.HasFeature(state.Features, protocol.FeatureBeliefConflicts)) - applyGoalProgress(&actor, proposal.GoalID, 1, "") - for _, update := range item.GoalUpdates { - applyGoalProgress(&actor, update.GoalID, update.ProgressDelta, update.Status) + applyFacts( + &actor, + item.Facts, + item.EventID, + tick, + revision, + protocol.HasFeature(state.Features, protocol.FeatureBeliefConflicts), + outcomeReporting, + ) + if outcomeReporting { + if err := applyGoalProgress(&actor, proposal.GoalID, 1, "", tick, item.EventID); err != nil { + return err + } + for _, update := range item.GoalUpdates { + if err := applyGoalProgress(&actor, update.GoalID, update.ProgressDelta, update.Status, tick, item.EventID); err != nil { + return err + } + } + } else { + applyLegacyGoalProgress(&actor, proposal.GoalID, 1, "") + for _, update := range item.GoalUpdates { + applyLegacyGoalProgress(&actor, update.GoalID, update.ProgressDelta, update.Status) + } } state.Actors[proposal.ActorID] = actor return nil @@ -333,17 +412,77 @@ func applyRestored(current protocol.SessionState, event protocol.EventRecord) (p if current.SessionID != "" && (restored.SessionID != current.SessionID || restored.Binding != current.Binding) { return protocol.SessionState{}, fmt.Errorf("%w: restore binding mismatch", ErrCorruptLog) } - restored.Proposals = make(map[string]protocol.ActionProposal) + outcomeReporting := protocol.HasFeature(restored.Features, protocol.FeatureOutcomeReporting) + if !outcomeReporting { + // Preserve the historical reducer for pre-feature logs. + restored.Proposals = make(map[string]protocol.ActionProposal) + } else { + // Pending proposals are retained so an Outcome Outbox captured with the + // same game save can report actions the authoritative game already + // applied before the save. Resolved proposals are already projected into + // actor state and do not need to cross the event-chain boundary. + for proposalID, proposal := range restored.Proposals { + if proposal.Status != "pending" { + delete(restored.Proposals, proposalID) + } + } + if restored.Proposals == nil { + restored.Proposals = make(map[string]protocol.ActionProposal) + } + rebaseRestoredRevisions(&restored, event.Sequence) + } if protocol.HasFeature(restored.Features, protocol.FeatureArbitration) { advanceWorldRevision(&restored) } if restored.Receipts == nil { restored.Receipts = make(map[string]protocol.RequestReceipt) } + if outcomeReporting { + // Receipt revisions belong to the event chain that produced the + // Snapshot. Rebase the restored generation so capacity trimming keeps + // the new restore receipt and later Outbox acknowledgements first. + for requestID, receipt := range restored.Receipts { + receipt.Revision = 0 + restored.Receipts[requestID] = receipt + } + } restored.Receipts[event.RequestID] = protocol.RequestReceipt{Kind: EventSessionRestored, EntityID: restored.SessionID, Revision: event.Sequence} return restored, nil } +func rebaseRestoredRevisions(state *protocol.SessionState, revision uint64) { + for actorID, actor := range state.Actors { + for index := range actor.Memories { + actor.Memories[index].CreatedRevision = revision + } + for index := range actor.MemorySummaries { + actor.MemorySummaries[index].CreatedRevision = revision + } + for key, set := range actor.BeliefSets { + for index := range set.Claims { + set.Claims[index].ObservedRevision = revision + } + actor.BeliefSets[key] = set + } + for index := range actor.RecentActions { + actor.RecentActions[index].CreatedRevision = revision + } + if actor.Activity != nil { + activity := *actor.Activity + activity.UpdatedRevision = revision + actor.Activity = &activity + } + state.Actors[actorID] = actor + } + for proposalID, proposal := range state.Proposals { + proposal.CreatedRevision = revision + state.Proposals[proposalID] = proposal + } + for index := range state.Arbitrations { + state.Arbitrations[index].CreatedRevision = revision + } +} + func advanceWorldRevision(state *protocol.SessionState) { if !protocol.HasFeature(state.Features, protocol.FeatureArbitration) { return @@ -354,7 +493,15 @@ func advanceWorldRevision(state *protocol.SessionState) { } } -func applyFacts(actor *protocol.ActorState, facts []protocol.Fact, eventID string, revision uint64, preserveConflicts bool) { +func applyFacts( + actor *protocol.ActorState, + facts []protocol.Fact, + eventID string, + tick int64, + revision uint64, + preserveConflicts bool, + outcomeReporting bool, +) { if actor.Beliefs == nil { actor.Beliefs = make(map[string]protocol.Fact) } @@ -366,8 +513,20 @@ func applyFacts(actor *protocol.ActorState, facts []protocol.Fact, eventID strin continue } fact.SourceEventID = eventID + if outcomeReporting { + fact.ObservedTick = tick + } key := fact.SubjectID + ":" + fact.Predicate if !preserveConflicts { + if outcomeReporting { + if current, exists := actor.Beliefs[key]; exists { + if current.ObservedTick > fact.ObservedTick || + (current.ObservedTick == fact.ObservedTick && + current.SourceEventID > fact.SourceEventID) { + continue + } + } + } actor.Beliefs[key] = fact continue } @@ -385,8 +544,8 @@ func applyFacts(actor *protocol.ActorState, facts []protocol.Fact, eventID strin if !updated { set.Claims = append(set.Claims, protocol.BeliefClaim{Fact: fact, ObservedRevision: revision}) } - trimBeliefClaims(&set) - selected := selectBeliefClaim(set.Claims) + trimBeliefClaims(&set, outcomeReporting) + selected := selectBeliefClaim(set.Claims, outcomeReporting) set.SelectedSourceEventID = selected.Fact.SourceEventID set.Conflicted = beliefObjectCount(set.Claims) > 1 actor.BeliefSets[key] = set @@ -394,35 +553,69 @@ func applyFacts(actor *protocol.ActorState, facts []protocol.Fact, eventID strin } } -func trimBeliefClaims(set *protocol.BeliefSet) { +func trimBeliefClaims(set *protocol.BeliefSet, outcomeReporting bool) { if len(set.Claims) <= 8 { return } + if !outcomeReporting { + sort.Slice(set.Claims, func(i, j int) bool { + if set.Claims[i].Fact.Confidence == set.Claims[j].Fact.Confidence { + if set.Claims[i].ObservedRevision == set.Claims[j].ObservedRevision { + return set.Claims[i].Fact.SourceEventID < set.Claims[j].Fact.SourceEventID + } + return set.Claims[i].ObservedRevision > set.Claims[j].ObservedRevision + } + return set.Claims[i].Fact.Confidence > set.Claims[j].Fact.Confidence + }) + set.Claims = append([]protocol.BeliefClaim(nil), set.Claims[:8]...) + return + } sort.Slice(set.Claims, func(i, j int) bool { - if set.Claims[i].Fact.Confidence == set.Claims[j].Fact.Confidence { - if set.Claims[i].ObservedRevision == set.Claims[j].ObservedRevision { - return set.Claims[i].Fact.SourceEventID < set.Claims[j].Fact.SourceEventID + if set.Claims[i].Fact.ObservedTick == set.Claims[j].Fact.ObservedTick { + if set.Claims[i].Fact.Confidence == set.Claims[j].Fact.Confidence { + if set.Claims[i].Fact.SourceEventID == set.Claims[j].Fact.SourceEventID { + return set.Claims[i].ObservedRevision > set.Claims[j].ObservedRevision + } + return set.Claims[i].Fact.SourceEventID > set.Claims[j].Fact.SourceEventID } - return set.Claims[i].ObservedRevision > set.Claims[j].ObservedRevision + return set.Claims[i].Fact.Confidence > set.Claims[j].Fact.Confidence } - return set.Claims[i].Fact.Confidence > set.Claims[j].Fact.Confidence + return set.Claims[i].Fact.ObservedTick > set.Claims[j].Fact.ObservedTick }) set.Claims = append([]protocol.BeliefClaim(nil), set.Claims[:8]...) } -func selectBeliefClaim(claims []protocol.BeliefClaim) protocol.BeliefClaim { +func selectBeliefClaim(claims []protocol.BeliefClaim, outcomeReporting bool) protocol.BeliefClaim { values := append([]protocol.BeliefClaim(nil), claims...) + if !outcomeReporting { + sort.Slice(values, func(i, j int) bool { + if values[i].Fact.Confidence == values[j].Fact.Confidence { + if values[i].ObservedRevision == values[j].ObservedRevision { + if values[i].Fact.Object == values[j].Fact.Object { + return values[i].Fact.SourceEventID < values[j].Fact.SourceEventID + } + return values[i].Fact.Object < values[j].Fact.Object + } + return values[i].ObservedRevision > values[j].ObservedRevision + } + return values[i].Fact.Confidence > values[j].Fact.Confidence + }) + return values[0] + } sort.Slice(values, func(i, j int) bool { - if values[i].Fact.Confidence == values[j].Fact.Confidence { - if values[i].ObservedRevision == values[j].ObservedRevision { - if values[i].Fact.Object == values[j].Fact.Object { - return values[i].Fact.SourceEventID < values[j].Fact.SourceEventID + if values[i].Fact.ObservedTick == values[j].Fact.ObservedTick { + if values[i].Fact.Confidence == values[j].Fact.Confidence { + if values[i].Fact.SourceEventID == values[j].Fact.SourceEventID { + if values[i].Fact.Object == values[j].Fact.Object { + return values[i].ObservedRevision > values[j].ObservedRevision + } + return values[i].Fact.Object > values[j].Fact.Object } - return values[i].Fact.Object < values[j].Fact.Object + return values[i].Fact.SourceEventID > values[j].Fact.SourceEventID } - return values[i].ObservedRevision > values[j].ObservedRevision + return values[i].Fact.Confidence > values[j].Fact.Confidence } - return values[i].Fact.Confidence > values[j].Fact.Confidence + return values[i].Fact.ObservedTick > values[j].Fact.ObservedTick }) return values[0] } @@ -435,7 +628,63 @@ func beliefObjectCount(claims []protocol.BeliefClaim) int { return len(objects) } -func applyGoalProgress(actor *protocol.ActorState, goalID string, delta int, status string) { +func applyGoalProgress(actor *protocol.ActorState, goalID string, delta int, status string, tick int64, eventID string) error { + if goalID == "" { + return nil + } + for index := range actor.Goals { + goal := &actor.Goals[index] + if goal.ID != goalID { + continue + } + accumulator := goal.ProgressAccumulator + // Snapshots created before occurrence metadata used Progress as the + // only stored value. Adopt it as the accumulator on first mutation. + if accumulator == 0 && goal.Progress != 0 { + accumulator = int64(goal.Progress) + } + change := int64(delta) + if (change > 0 && accumulator > maxInt64-change) || + (change < 0 && accumulator < minInt64-change) { + return fmt.Errorf("%w: goal progress accumulator overflow", ErrCorruptLog) + } + accumulator += change + goal.ProgressAccumulator = accumulator + if accumulator < 0 { + goal.Progress = 0 + } else if accumulator > int64(goal.TargetProgress) { + goal.Progress = goal.TargetProgress + } else { + goal.Progress = int(accumulator) + } + if status != "" && shouldReplaceGoalStatus(*goal, tick, eventID) { + goal.Status = status + goal.StatusExplicit = true + goal.StatusUpdatedTick = tick + goal.StatusSourceEventID = eventID + } else if status == "" && !goal.StatusExplicit { + if goal.Progress >= goal.TargetProgress { + goal.Status = "completed" + } else { + goal.Status = "active" + } + } + if tick > goal.UpdatedTick { + goal.UpdatedTick = tick + } + return nil + } + return nil +} + +func shouldReplaceGoalStatus(goal protocol.Goal, tick int64, eventID string) bool { + if !goal.StatusExplicit || tick > goal.StatusUpdatedTick { + return true + } + return tick == goal.StatusUpdatedTick && eventID > goal.StatusSourceEventID +} + +func applyLegacyGoalProgress(actor *protocol.ActorState, goalID string, delta int, status string) { if goalID == "" { return } @@ -460,7 +709,7 @@ func applyGoalProgress(actor *protocol.ActorState, goalID string, delta int, sta } } -func markRecalled(actor *protocol.ActorState, ids []string, tick int64) { +func markRecalled(actor *protocol.ActorState, ids []string, tick int64, outcomeReporting bool) { selected := make(map[string]struct{}, len(ids)) for _, id := range ids { selected[id] = struct{}{} @@ -468,17 +717,33 @@ func markRecalled(actor *protocol.ActorState, ids []string, tick int64) { for index := range actor.Memories { if _, exists := selected[actor.Memories[index].ID]; exists { actor.Memories[index].RecallCount++ - actor.Memories[index].LastRecalledTick = tick + if !outcomeReporting || tick > actor.Memories[index].LastRecalledTick { + actor.Memories[index].LastRecalledTick = tick + } } } for index := range actor.MemorySummaries { if _, exists := selected[actor.MemorySummaries[index].ID]; exists { actor.MemorySummaries[index].RecallCount++ - actor.MemorySummaries[index].LastRecalledTick = tick + if !outcomeReporting || tick > actor.MemorySummaries[index].LastRecalledTick { + actor.MemorySummaries[index].LastRecalledTick = tick + } } } } +func sortActorMemories(actor *protocol.ActorState) { + sort.SliceStable(actor.Memories, func(i, j int) bool { + if actor.Memories[i].Tick == actor.Memories[j].Tick { + if actor.Memories[i].EventID != actor.Memories[j].EventID { + return actor.Memories[i].EventID < actor.Memories[j].EventID + } + return actor.Memories[i].ID < actor.Memories[j].ID + } + return actor.Memories[i].Tick < actor.Memories[j].Tick + }) +} + func trimProposals(state *protocol.SessionState) { if len(state.Proposals) <= maxProposals { return diff --git a/runtime/runtime.go b/runtime/runtime.go index 42eb001..f5d2c5a 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -29,7 +29,18 @@ const ( ) type Store interface { + // Create is an idempotent create-if-absent operation. Repeating an + // identical first EventRecord, including the exact Data bytes, is a + // successful durability confirmation; any different existing log must + // return ErrConflict without mutation. Create(sessionID string, event protocol.EventRecord) error + // Append must compare the event with the current tail. Repeating an + // identical EventRecord, including the exact Data bytes, is an idempotent + // success; a different event at the same sequence or an unexpected + // previous hash must fail without mutation. + // It must never accept a partial record as a valid event. If an underlying + // write or rollback fails, Load must surface the incomplete tail as + // ErrCorruptLog instead of silently replaying it. Append(sessionID string, event protocol.EventRecord) error Load(sessionID string) ([]protocol.EventRecord, error) ListSessions() ([]string, error) diff --git a/sdk/README.md b/sdk/README.md index f24c555..cd42f2c 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -22,7 +22,11 @@ All clients follow these rules: - redirects are rejected; - request timeouts and response-size limits are mandatory; - errors expose bounded Rin codes, not provider bodies or credentials; -- proposals remain pending until the game applies and commits them. +- proposals remain pending until the game applies or rejects them and reports + the result with Commit; Commit records an outcome and is not authorization. + +That final rule applies to Sessions which explicitly request +`outcome-reporting-v1`; clients must not assume it for legacy Sessions. The SDKs are intentionally source-first and are not yet published to PyPI, npm, NuGet, or Maven Central. Pin this repository revision when vendoring one. @@ -33,4 +37,7 @@ show where host events enter Rin and where the game validates and applies a proposal. They are integration templates, not universal patches for every game version. +All SDKs follow the Commit lifecycle, Outbox, and retry rules in +[`docs/outcome-reporting.md`](../docs/outcome-reporting.md). + The SDK source is released under the [MIT License](../LICENSE). diff --git a/sdk/README.zh-CN.md b/sdk/README.zh-CN.md index d7f3791..e6bf028 100644 --- a/sdk/README.zh-CN.md +++ b/sdk/README.zh-CN.md @@ -21,7 +21,11 @@ - 拒绝重定向; - 强制请求超时和响应大小限制; - 错误只暴露有界 Rin Code,不暴露供应商正文或凭据; -- Proposal 保持 Pending,直到游戏应用并 Commit。 +- Proposal 保持 Pending,直到游戏应用或拒绝后用 Commit 回报结果;Commit + 是结果记账,不是执行授权。 + +最后一条仅适用于显式请求 `outcome-reporting-v1` 的 Session;客户端不能对 +旧 Session 假设该语义。 SDK 有意采用源码优先方式,尚未发布到 PyPI、npm、NuGet 或 Maven Central。 Vendor 时应固定本仓库 Revision。路由兼容性由 @@ -31,4 +35,7 @@ Vendor 时应固定本仓库 Revision。路由兼容性由 如何进入 Rin,以及游戏在何处验证并应用 Proposal。它们是接入模板,不是 适用于每个游戏版本的通用补丁。 +所有 SDK 的 Commit 生命周期、Outbox 和重试规则以 +[`docs/outcome-reporting.zh-CN.md`](../docs/outcome-reporting.zh-CN.md) 为准。 + SDK 源码按 [MIT License](../LICENSE) 发布。 diff --git a/sdk/csharp/Rin.Client.Tests/Program.cs b/sdk/csharp/Rin.Client.Tests/Program.cs index 6e83c64..acb3923 100644 --- a/sdk/csharp/Rin.Client.Tests/Program.cs +++ b/sdk/csharp/Rin.Client.Tests/Program.cs @@ -74,6 +74,277 @@ Require(exception.Code == "transport_timeout", "wrong timeout error"); } +var proposalRace = new RecordingHandler +{ + ResponseBodyFactory = request => request.Method == HttpMethod.Delete + ? ProposalJobBody( + "succeeded", + ",\"proposal\":{\"id\":\"proposal.race\",\"session_id\":\"session.fixture\",\"request_id\":\"request.fixture\",\"actor_id\":\"actor.fixture\",\"tick\":7}") + : ProposalJobBody("running"), +}; +using var proposalRaceClient = new RinClient(new RinClientOptions(), proposalRace); +var proposalRaceJob = await proposalRaceClient.WaitForProposalAsync( + "job.fixture", + TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(10)); +Require( + proposalRaceJob.GetProperty("proposal").GetProperty("id").GetString() == "proposal.race", + "proposal completion returned by cancellation was discarded"); + +var generationRace = new RecordingHandler +{ + ResponseBodyFactory = request => request.Method == HttpMethod.Delete + ? GenerationJobBody("succeeded", ",\"result\":{\"content\":\"finished at the deadline\"}") + : GenerationJobBody("queued"), +}; +using var generationRaceClient = new RinClient(new RinClientOptions(), generationRace); +var generationRaceJob = await generationRaceClient.WaitForGenerationAsync( + "job.fixture", + TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(10)); +Require( + generationRaceJob.GetProperty("result").GetProperty("content").GetString() == "finished at the deadline", + "generation completion returned by cancellation was discarded"); + +var terminalCancel = new RecordingHandler +{ + ResponseBodyFactory = request => request.Method == HttpMethod.Delete + ? ProposalJobBody("stale", ",\"error\":{\"code\":\"proposal_stale\",\"message\":\"World changed\"}") + : ProposalJobBody("running"), +}; +using var terminalCancelClient = new RinClient(new RinClientOptions(), terminalCancel); +try +{ + await terminalCancelClient.WaitForProposalAsync( + "job.fixture", + TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(10)); + throw new InvalidOperationException("terminal cancellation result was discarded"); +} +catch (RinApiException exception) +{ + Require(exception.Code == "proposal_stale", "terminal cancellation result became job_timeout"); +} + +var canceledDuringGet = new CancellationReconciliationHandler( + ProposalJobBody( + "succeeded", + ",\"proposal\":{\"id\":\"proposal.after-cancel\",\"session_id\":\"session.fixture\",\"request_id\":\"request.fixture\",\"actor_id\":\"actor.fixture\",\"tick\":8}"), + blockGetUntilCanceled: true); +using var canceledDuringGetClient = new RinClient(new RinClientOptions(), canceledDuringGet); +using (var callerCancellation = new CancellationTokenSource()) +{ + var wait = canceledDuringGetClient.WaitForProposalAsync( + "job.fixture", + TimeSpan.FromSeconds(5), + TimeSpan.FromMilliseconds(10), + callerCancellation.Token); + await canceledDuringGet.GetStarted; + callerCancellation.Cancel(); + var reconciled = await wait; + Require(canceledDuringGet.DeleteCount == 1, "caller cancellation during GET did not issue DELETE"); + Require( + reconciled.GetProperty("proposal").GetProperty("id").GetString() == "proposal.after-cancel", + "proposal raced with caller cancellation was discarded"); +} + +var confirmedCallerCancellation = new CancellationReconciliationHandler( + ProposalJobBody("canceled")); +using var confirmedCallerCancellationClient = new RinClient(new RinClientOptions(), confirmedCallerCancellation); +using (var callerCancellation = new CancellationTokenSource()) +{ + var wait = confirmedCallerCancellationClient.WaitForProposalAsync( + "job.fixture", + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(5), + callerCancellation.Token); + await confirmedCallerCancellation.GetStarted; + callerCancellation.Cancel(); + try + { + await wait; + throw new InvalidOperationException("confirmed caller cancellation did not remain canceled"); + } + catch (OperationCanceledException) + { + Require(callerCancellation.IsCancellationRequested, "wrong cancellation was propagated"); + } + Require(confirmedCallerCancellation.DeleteCount == 1, "caller cancellation during delay did not issue DELETE"); +} + +var unconfirmedCallerCancellation = new CancellationReconciliationHandler( + ProposalJobBody("running")); +using var unconfirmedCallerCancellationClient = new RinClient(new RinClientOptions(), unconfirmedCallerCancellation); +using (var callerCancellation = new CancellationTokenSource()) +{ + var wait = unconfirmedCallerCancellationClient.WaitForProposalAsync( + "job.fixture", + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(5), + callerCancellation.Token); + await unconfirmedCallerCancellation.GetStarted; + callerCancellation.Cancel(); + try + { + await wait; + throw new InvalidOperationException("unconfirmed caller cancellation was treated as safe"); + } + catch (RinApiException exception) + { + Require(exception.Code == "job_outcome_unknown", "unresolved DELETE returned the wrong error"); + } +} + +var staleCallerReconciliation = new CancellationReconciliationHandler( + ProposalJobBody("stale", ",\"error\":{\"code\":\"proposal_stale\",\"message\":\"World changed\"}")); +using var staleCallerReconciliationClient = new RinClient(new RinClientOptions(), staleCallerReconciliation); +using (var callerCancellation = new CancellationTokenSource()) +{ + var wait = staleCallerReconciliationClient.WaitForProposalAsync( + "job.fixture", + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(5), + callerCancellation.Token); + await staleCallerReconciliation.GetStarted; + callerCancellation.Cancel(); + try + { + await wait; + throw new InvalidOperationException("stale cancellation reconciliation was discarded"); + } + catch (RinApiException exception) + { + Require(exception.Code == "proposal_stale", "stale DELETE terminal result was not propagated"); + } +} + +var failedCallerReconciliation = new CancellationReconciliationHandler( + "{\"ok\":false,\"error\":{\"code\":\"cancel_failed\",\"message\":\"Cancellation failed\"}}"); +using var failedCallerReconciliationClient = new RinClient(new RinClientOptions(), failedCallerReconciliation); +using (var callerCancellation = new CancellationTokenSource()) +{ + var wait = failedCallerReconciliationClient.WaitForProposalAsync( + "job.fixture", + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(5), + callerCancellation.Token); + await failedCallerReconciliation.GetStarted; + callerCancellation.Cancel(); + try + { + await wait; + throw new InvalidOperationException("failed cancellation reconciliation was treated as safe"); + } + catch (RinApiException exception) + { + Require(exception.Code == "job_cancel_unconfirmed", "failed DELETE returned the wrong error"); + } +} + +var malformedCallerReconciliation = new CancellationReconciliationHandler("not-json"); +using var malformedCallerReconciliationClient = new RinClient(new RinClientOptions(), malformedCallerReconciliation); +using (var callerCancellation = new CancellationTokenSource()) +{ + var wait = malformedCallerReconciliationClient.WaitForProposalAsync( + "job.fixture", + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(5), + callerCancellation.Token); + await malformedCallerReconciliation.GetStarted; + callerCancellation.Cancel(); + try + { + await wait; + throw new InvalidOperationException("malformed cancellation reconciliation was treated as safe"); + } + catch (RinApiException exception) + { + Require(exception.Code == "job_cancel_unconfirmed", "malformed DELETE returned the wrong error"); + } +} + +var crossedGet = new RecordingHandler +{ + ResponseBodyFactory = _ => ProposalJobBody("running", jobId: "job.other"), +}; +using var crossedGetClient = new RinClient(new RinClientOptions(), crossedGet); +try +{ + await crossedGetClient.WaitForProposalAsync("job.fixture"); + throw new InvalidOperationException("crossed GET job identity was accepted"); +} +catch (RinProtocolException exception) +{ + Require(exception.Code == "invalid_job", "crossed GET returned the wrong error"); +} + +foreach (var malformedStatus in new[] { "", " canceled ", "canceled\\u0000" }) +{ + var malformedStatusGet = new RecordingHandler + { + ResponseBodyFactory = _ => ProposalJobBody(malformedStatus), + }; + using var malformedStatusGetClient = new RinClient(new RinClientOptions(), malformedStatusGet); + try + { + await malformedStatusGetClient.WaitForProposalAsync("job.fixture"); + throw new InvalidOperationException("polling accepted a normalized pseudo-status"); + } + catch (RinProtocolException exception) + { + Require(exception.Code == "invalid_job", "malformed polling status returned the wrong error"); + } +} + +foreach (var malformedStatus in new[] { "", " canceled ", "canceled\\u0000" }) +{ + var malformedStatusCancellation = new CancellationReconciliationHandler( + ProposalJobBody(malformedStatus)); + using var malformedStatusCancellationClient = + new RinClient(new RinClientOptions(), malformedStatusCancellation); + using var callerCancellation = new CancellationTokenSource(); + var wait = malformedStatusCancellationClient.WaitForProposalAsync( + "job.fixture", + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(5), + callerCancellation.Token); + await malformedStatusCancellation.GetStarted; + callerCancellation.Cancel(); + try + { + await wait; + throw new InvalidOperationException("caller cancellation accepted a normalized pseudo-status"); + } + catch (RinApiException exception) + { + Require( + exception.Code == "job_outcome_unknown", + "malformed cancellation status returned the wrong error"); + } +} + +var malformedDelete = new RecordingHandler +{ + ResponseBodyFactory = request => request.Method == HttpMethod.Delete + ? ProposalJobBody( + "succeeded", + ",\"proposal\":{\"id\":\"proposal.race\",\"session_id\":\"session.fixture\",\"request_id\":\"request.fixture\",\"actor_id\":\"actor.fixture\",\"tick\":1.5}") + : ProposalJobBody("running"), +}; +using var malformedDeleteClient = new RinClient(new RinClientOptions(), malformedDelete); +try +{ + await malformedDeleteClient.WaitForProposalAsync( + "job.fixture", + TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(10)); + throw new InvalidOperationException("malformed DELETE proposal identity was accepted"); +} +catch (RinProtocolException exception) +{ + Require(exception.Code == "invalid_job", "malformed DELETE returned the wrong error"); +} + Console.WriteLine("Rin C# SDK tests passed"); static void Require(bool condition, string message) @@ -93,6 +364,26 @@ static void RequireThrows(Action action, string message) where TExce } } +static string ProposalJobBody( + string status, + string suffix = "", + string jobId = "job.fixture", + string sessionId = "session.fixture", + string requestId = "request.fixture") => + "{\"ok\":true,\"data\":{\"job_id\":\"" + jobId + + "\",\"session_id\":\"" + sessionId + + "\",\"request_id\":\"" + requestId + + "\",\"status\":\"" + status + "\"" + suffix + "}}"; + +static string GenerationJobBody( + string status, + string suffix = "", + string jobId = "job.fixture", + string requestId = "generation.fixture") => + "{\"ok\":true,\"data\":{\"job_id\":\"" + jobId + + "\",\"request_id\":\"" + requestId + + "\",\"status\":\"" + status + "\"" + suffix + "}}"; + sealed class RecordingHandler : HttpMessageHandler { public HttpMethod? Method { get; private set; } @@ -100,6 +391,7 @@ sealed class RecordingHandler : HttpMessageHandler public string Authorization { get; private set; } = string.Empty; public long? DeclaredLength { get; init; } public Func? ContentFactory { get; init; } + public Func? ResponseBodyFactory { get; init; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { @@ -112,8 +404,11 @@ protected override Task SendAsync(HttpRequestMessage reques var status = Path is "/v1/jobs/propose" or "/v1/generation/jobs" ? HttpStatusCode.Accepted : HttpStatusCode.OK; - var content = ContentFactory?.Invoke() ?? - new ByteArrayContent(Encoding.UTF8.GetBytes("{\"ok\":true,\"data\":{\"status\":\"ok\"}}")); + var responseBodyFactory = ResponseBodyFactory; + var content = responseBodyFactory is not null + ? new ByteArrayContent(Encoding.UTF8.GetBytes(responseBodyFactory(request))) + : ContentFactory?.Invoke() ?? + new ByteArrayContent(Encoding.UTF8.GetBytes("{\"ok\":true,\"data\":{\"status\":\"ok\"}}")); if (DeclaredLength.HasValue) content.Headers.ContentLength = DeclaredLength.Value; return Task.FromResult(new HttpResponseMessage(status) { Content = content }); } @@ -145,3 +440,51 @@ public override async ValueTask ReadAsync( return 0; } } + +sealed class CancellationReconciliationHandler : HttpMessageHandler +{ + private readonly string deleteResponseBody; + private readonly bool blockGetUntilCanceled; + private readonly TaskCompletionSource getStarted = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public CancellationReconciliationHandler(string deleteResponseBody, bool blockGetUntilCanceled = false) + { + this.deleteResponseBody = deleteResponseBody; + this.blockGetUntilCanceled = blockGetUntilCanceled; + } + + public Task GetStarted => getStarted.Task; + + public int DeleteCount { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (request.Method == HttpMethod.Delete) + { + if (cancellationToken.IsCancellationRequested) + { + throw new InvalidOperationException("DELETE reused the canceled caller token"); + } + DeleteCount++; + return Response(deleteResponseBody); + } + + getStarted.TrySetResult(true); + if (blockGetUntilCanceled) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + return Response( + "{\"ok\":true,\"data\":{\"job_id\":\"job.fixture\",\"session_id\":\"session.fixture\"," + + "\"request_id\":\"request.fixture\",\"status\":\"running\"}}"); + } + + private static HttpResponseMessage Response(string body) => + new(HttpStatusCode.OK) + { + Content = new ByteArrayContent(Encoding.UTF8.GetBytes(body)), + }; +} diff --git a/sdk/csharp/Rin.Client/RinClient.cs b/sdk/csharp/Rin.Client/RinClient.cs index e7474ae..f32baee 100644 --- a/sdk/csharp/Rin.Client/RinClient.cs +++ b/sdk/csharp/Rin.Client/RinClient.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Net; using System.Net.Http.Headers; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -11,6 +12,8 @@ public sealed class RinClient : IDisposable public const string ProtocolVersion = "rin.protocol/v1"; public const string DefaultBaseUrl = "http://127.0.0.1:7374"; + private const int MaxGenerationContentBytes = 4 * 1024 * 1024; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -82,9 +85,11 @@ public Task GetGenerationJobAsync(string jobId, CancellationToken c public Task CancelGenerationJobAsync(string jobId, CancellationToken cancellationToken = default) => RequestAsync(HttpMethod.Delete, "/v1/generation/jobs/" + PathId(jobId), null, 200, cancellationToken); + /// Reports an outcome the game already applied or rejected. public Task CommitAsync(object payload, CancellationToken cancellationToken = default) => PostAsync("/v1/action/commit", payload, 200, cancellationToken); + /// Atomically reports outcomes produced from one original world revision. public Task CommitBatchAsync(object payload, CancellationToken cancellationToken = default) => PostAsync("/v1/action/commit-batch", payload, 200, cancellationToken); @@ -123,6 +128,7 @@ public Task WaitForProposalAsync( CancelProposalJobAsync, deadline ?? TimeSpan.FromSeconds(25), interval ?? TimeSpan.FromMilliseconds(100), + JobResultKind.Proposal, cancellationToken); public Task WaitForGenerationAsync( @@ -136,6 +142,7 @@ public Task WaitForGenerationAsync( CancelGenerationJobAsync, deadline ?? TimeSpan.FromSeconds(45), interval ?? TimeSpan.FromMilliseconds(100), + JobResultKind.Generation, cancellationToken); public void Dispose() => httpClient.Dispose(); @@ -152,6 +159,7 @@ private static async Task WaitForJobAsync( Func> canceler, TimeSpan deadline, TimeSpan interval, + JobResultKind resultKind, CancellationToken cancellationToken) { if (deadline < TimeSpan.FromMilliseconds(50) || deadline > TimeSpan.FromMinutes(5) || @@ -160,33 +168,234 @@ private static async Task WaitForJobAsync( throw new RinConfigurationException("invalid_polling", "Job deadline or interval is out of range"); } var elapsed = Stopwatch.StartNew(); - while (true) + try { - var job = await getter(jobId, cancellationToken).ConfigureAwait(false); - var status = TextProperty(job, "status", 32); - if (status == "succeeded") return job; - if (status is "failed" or "stale" or "canceled") + while (true) { - var detail = job.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object - ? error - : default; - throw new RinApiException( - TextProperty(detail, "code", 96, "job_" + status), - TextProperty(detail, "message", 500, "Rin job ended as " + status)); + var job = await getter(jobId, cancellationToken).ConfigureAwait(false); + if (IsResolvedJob(job, resultKind, jobId)) return job; + var remaining = deadline - elapsed.Elapsed; + if (remaining <= TimeSpan.Zero) + { + JsonElement canceledJob; + try + { + canceledJob = await canceler(jobId, CancellationToken.None).ConfigureAwait(false); + } + catch (RinException) + { + throw new RinApiException("job_timeout", "Rin job exceeded its deadline"); + } + if (IsResolvedJob(canceledJob, resultKind, jobId)) return canceledJob; + throw new RinApiException("job_timeout", "Rin job exceeded its deadline"); + } + await Task.Delay(interval < remaining ? interval : remaining, cancellationToken).ConfigureAwait(false); } - if (status is not ("queued" or "running")) + } + catch (OperationCanceledException callerCancellation) when (cancellationToken.IsCancellationRequested) + { + return await ReconcileCallerCancellationAsync( + jobId, + canceler, + resultKind, + callerCancellation).ConfigureAwait(false); + } + } + + private static async Task ReconcileCallerCancellationAsync( + string jobId, + Func> canceler, + JobResultKind resultKind, + OperationCanceledException callerCancellation) + { + JsonElement canceledJob; + try + { + // The caller token is already canceled. Reconciliation must get its own + // request deadline so a raced, durable proposal cannot be discarded. + canceledJob = await canceler(jobId, CancellationToken.None).ConfigureAwait(false); + } + catch (RinException) + { + throw new RinApiException( + "job_cancel_unconfirmed", + "Caller cancellation could not be confirmed with Rin"); + } + catch (OperationCanceledException) + { + throw new RinApiException( + "job_cancel_unconfirmed", + "Caller cancellation could not be confirmed with Rin"); + } + + try + { + ValidateJobIdentity(canceledJob, resultKind, jobId, out _, out _); + var status = RequiredRawJobStatus(canceledJob); + if (status == "canceled") { - throw new RinProtocolException("invalid_job", "Rin returned an unknown job status"); + throw callerCancellation; } - var remaining = deadline - elapsed.Elapsed; - if (remaining <= TimeSpan.Zero) + if (IsResolvedJob(canceledJob, resultKind, jobId)) return canceledJob; + } + catch (RinProtocolException) + { + throw new RinApiException( + "job_outcome_unknown", + "Rin returned an invalid cancellation outcome"); + } + + throw new RinApiException( + "job_outcome_unknown", + "Rin did not confirm a terminal job outcome after caller cancellation"); + } + + private static bool IsResolvedJob( + JsonElement job, + JobResultKind resultKind, + string expectedJobId) + { + ValidateJobIdentity(job, resultKind, expectedJobId, out var jobSessionId, out var jobRequestId); + var status = RequiredRawJobStatus(job); + if (status == "succeeded") + { + if (resultKind == JobResultKind.Proposal) { - try { await canceler(jobId, CancellationToken.None).ConfigureAwait(false); } - catch (RinException) { } - throw new RinApiException("job_timeout", "Rin job exceeded its deadline"); + if (!job.TryGetProperty("proposal", out var proposal) || proposal.ValueKind != JsonValueKind.Object) + { + throw new RinProtocolException("invalid_job", "Successful proposal job did not include a proposal"); + } + if (!TryIdentifierProperty(proposal, "id", out _) || + !TryIdentifierProperty(proposal, "actor_id", out _) || + !TryIdentifierProperty(proposal, "session_id", out var proposalSessionId) || + !TryIdentifierProperty(proposal, "request_id", out var proposalRequestId) || + proposalSessionId != jobSessionId || + proposalRequestId != jobRequestId || + !TryNonnegativeInt64Property(proposal, "tick")) + { + throw new RinProtocolException( + "invalid_job", + "Successful proposal job contained invalid identity fields"); + } + } + if (resultKind == JobResultKind.Generation) + { + if (!job.TryGetProperty("result", out var result) || result.ValueKind != JsonValueKind.Object || + !result.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.String) + { + throw new RinProtocolException("invalid_job", "Successful generation job did not include content"); + } + var value = content.GetString(); + if (string.IsNullOrWhiteSpace(value) || + value.Contains('\0') || + Encoding.UTF8.GetByteCount(value) > MaxGenerationContentBytes) + { + throw new RinProtocolException("invalid_job", "Successful generation job did not include bounded content"); + } } - await Task.Delay(interval < remaining ? interval : remaining, cancellationToken).ConfigureAwait(false); + return true; + } + if (status is "failed" or "stale" or "canceled") + { + var detail = job.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object + ? error + : default; + throw new RinApiException( + TextProperty(detail, "code", 96, "job_" + status), + TextProperty(detail, "message", 500, "Rin job ended as " + status)); + } + if (status is not ("queued" or "running")) + { + throw new RinProtocolException("invalid_job", "Rin returned an unknown job status"); + } + return false; + } + + private static string RequiredRawJobStatus(JsonElement job) + { + if (job.ValueKind != JsonValueKind.Object || + !job.TryGetProperty("status", out var property) || + property.ValueKind != JsonValueKind.String) + { + throw new RinProtocolException("invalid_job", "Rin job status must be a string"); + } + var status = property.GetString(); + if (status is not ("queued" or "running" or "succeeded" or "failed" or "stale" or "canceled")) + { + throw new RinProtocolException("invalid_job", "Rin returned an unknown job status"); + } + return status; + } + + private static void ValidateJobIdentity( + JsonElement job, + JobResultKind resultKind, + string expectedJobId, + out string sessionId, + out string requestId) + { + sessionId = string.Empty; + requestId = string.Empty; + if (job.ValueKind != JsonValueKind.Object || + !TryIdentifierProperty(job, "job_id", out var responseJobId) || + responseJobId != expectedJobId) + { + throw new RinProtocolException( + "invalid_job", + "Rin returned a job with an invalid or mismatched job_id"); + } + if (resultKind == JobResultKind.Proposal && + (!TryIdentifierProperty(job, "session_id", out sessionId) || + !TryIdentifierProperty(job, "request_id", out requestId))) + { + throw new RinProtocolException("invalid_job", "Rin returned a proposal job with invalid identity fields"); } + if (resultKind == JobResultKind.Generation && + !TryIdentifierProperty(job, "request_id", out requestId)) + { + throw new RinProtocolException("invalid_job", "Rin returned a generation job with an invalid request_id"); + } + } + + private static bool TryIdentifierProperty(JsonElement element, string name, out string value) + { + value = string.Empty; + if (element.ValueKind != JsonValueKind.Object || + !element.TryGetProperty(name, out var property) || + property.ValueKind != JsonValueKind.String) + { + return false; + } + value = property.GetString() ?? string.Empty; + return IsProtocolIdentifier(value); + } + + private static bool IsProtocolIdentifier(string value) + { + if (value.Length is < 1 or > 96 || !IsAsciiLetterOrDigit(value[0])) return false; + return value.All(character => IsAsciiLetterOrDigit(character) || character is '.' or '_' or '-'); + } + + private static bool IsAsciiLetterOrDigit(char value) => + value is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'; + + private static bool TryNonnegativeInt64Property(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out var property) || + property.ValueKind != JsonValueKind.Number || + !property.TryGetInt64(out var value) || + value < 0) + { + return false; + } + var token = property.GetRawText(); + return token.Length > 0 && token.All(character => character is >= '0' and <= '9'); + } + + private enum JobResultKind + { + Proposal, + Generation, } private Task PostAsync(string path, object payload, int expectedStatus, CancellationToken cancellationToken) diff --git a/sdk/java/src/main/java/io/github/sunrioa/rin/RinClient.java b/sdk/java/src/main/java/io/github/sunrioa/rin/RinClient.java index 3c0f571..2c1fd77 100644 --- a/sdk/java/src/main/java/io/github/sunrioa/rin/RinClient.java +++ b/sdk/java/src/main/java/io/github/sunrioa/rin/RinClient.java @@ -1,6 +1,8 @@ package io.github.sunrioa.rin; import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.math.BigInteger; import java.net.URI; import java.net.URISyntaxException; import java.net.http.HttpClient; @@ -13,8 +15,8 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Collections; -import java.util.List; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -30,6 +32,10 @@ public final class RinClient { public static final String DEFAULT_BASE_URL = "http://127.0.0.1:7374"; public static final int DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; + private static final int MAX_GENERATION_CONTENT_BYTES = 4 * 1024 * 1024; + private static final long MAX_SAFE_DOUBLE_INTEGER = 9_007_199_254_740_991L; + private static final int MAX_SAFE_FLOAT_INTEGER = 16_777_215; + private final String baseUrl; private final String token; private final Duration timeout; @@ -99,10 +105,12 @@ public CompletableFuture> cancelGenerationJob(String jobId) return request("DELETE", "/v1/generation/jobs/" + pathId(jobId), null, Set.of(200)); } + /** Reports an outcome the game already applied or rejected. */ public CompletableFuture> commit(Map payload) { return post("/v1/action/commit", payload, 200); } + /** Atomically reports outcomes produced from one original world revision. */ public CompletableFuture> commitBatch(Map payload) { return post("/v1/action/commit-batch", payload, 200); } @@ -140,19 +148,19 @@ public CompletableFuture> dueAgents(Map payload) } public CompletableFuture> waitForProposal(String jobId) { - return waitForJob(jobId, this::getProposalJob, this::cancelProposalJob, Duration.ofSeconds(25), Duration.ofMillis(100)); + return waitForJob(jobId, this::getProposalJob, this::cancelProposalJob, Duration.ofSeconds(25), Duration.ofMillis(100), "proposal"); } public CompletableFuture> waitForProposal(String jobId, Duration deadline, Duration interval) { - return waitForJob(jobId, this::getProposalJob, this::cancelProposalJob, deadline, interval); + return waitForJob(jobId, this::getProposalJob, this::cancelProposalJob, deadline, interval, "proposal"); } public CompletableFuture> waitForGeneration(String jobId) { - return waitForJob(jobId, this::getGenerationJob, this::cancelGenerationJob, Duration.ofSeconds(45), Duration.ofMillis(100)); + return waitForJob(jobId, this::getGenerationJob, this::cancelGenerationJob, Duration.ofSeconds(45), Duration.ofMillis(100), "generation"); } public CompletableFuture> waitForGeneration(String jobId, Duration deadline, Duration interval) { - return waitForJob(jobId, this::getGenerationJob, this::cancelGenerationJob, deadline, interval); + return waitForJob(jobId, this::getGenerationJob, this::cancelGenerationJob, deadline, interval, "generation"); } private CompletableFuture> waitForJob( @@ -160,7 +168,8 @@ private CompletableFuture> waitForJob( Function>> getter, Function>> canceler, Duration deadline, - Duration interval) { + Duration interval, + String resultKind) { if (deadline == null || interval == null || deadline.compareTo(Duration.ofMillis(50)) < 0 || deadline.compareTo(Duration.ofMinutes(5)) > 0 || interval.compareTo(Duration.ofMillis(10)) < 0 || interval.compareTo(Duration.ofSeconds(5)) > 0) { @@ -169,6 +178,64 @@ private CompletableFuture> waitForJob( long expires = System.nanoTime() + deadline.toNanos(); CompletableFuture> result = new CompletableFuture<>(); class Poller { + boolean resolve(Map job) { + if (job == null) { + result.completeExceptionally(new RinProtocolException("invalid_job", "Rin returned an invalid job")); + return true; + } + try { + validateJobIdentity(job, resultKind, jobId); + } catch (RinProtocolException invalid) { + result.completeExceptionally(invalid); + return true; + } + Object statusValue = job.get("status"); + if (!(statusValue instanceof String status)) { + result.completeExceptionally(new RinProtocolException( + "invalid_job", + "Rin returned an invalid job status")); + return true; + } + if (status.equals("succeeded")) { + if (resultKind.equals("proposal") && + (!(job.get("proposal") instanceof Map proposal) || + !validProposalIdentity(proposal, job))) { + result.completeExceptionally(new RinProtocolException( + "invalid_job", + "Successful proposal job contained invalid identity fields")); + } else if (resultKind.equals("generation") && + (!(job.get("result") instanceof Map generationResult) || + !(generationResult.get("content") instanceof String content) || + !validGenerationContent(content))) { + result.completeExceptionally(new RinProtocolException( + "invalid_job", + "Successful generation job did not include bounded content")); + } else { + result.complete(job); + } + return true; + } + if (status.equals("failed") || status.equals("stale") || status.equals("canceled")) { + Object value = job.get("error"); + Map detail = value instanceof Map map ? map : Map.of(); + result.completeExceptionally(new RinApiException( + RinException.safeText(detail.get("code"), 96, "job_" + status), + RinException.safeText(detail.get("message"), 500, "Rin job ended as " + status), + 0, + "")); + return true; + } + if (!status.equals("queued") && !status.equals("running")) { + result.completeExceptionally(new RinProtocolException("invalid_job", "Rin returned an unknown job status")); + return true; + } + return false; + } + + void timeout() { + result.completeExceptionally(new RinApiException("job_timeout", "Rin job exceeded its deadline", 0, "")); + } + void poll() { if (result.isDone()) return; getter.apply(jobId).whenComplete((job, failure) -> { @@ -177,33 +244,33 @@ void poll() { result.completeExceptionally(unwrap(failure)); return; } - String status = RinException.safeText(job.get("status"), 32, ""); - if (status.equals("succeeded")) { - result.complete(job); - return; - } - if (status.equals("failed") || status.equals("stale") || status.equals("canceled")) { - Object value = job.get("error"); - Map detail = value instanceof Map map ? map : Map.of(); - result.completeExceptionally(new RinApiException( - RinException.safeText(detail.get("code"), 96, "job_" + status), - RinException.safeText(detail.get("message"), 500, "Rin job ended as " + status), - 0, - "")); - return; - } - if (!status.equals("queued") && !status.equals("running")) { - result.completeExceptionally(new RinProtocolException("invalid_job", "Rin returned an unknown job status")); - return; - } + if (resolve(job)) return; long remaining = expires - System.nanoTime(); if (remaining <= 0) { + CompletableFuture> cancellation; try { - canceler.apply(jobId); + cancellation = canceler.apply(jobId); } catch (RinException ignored) { - // Timeout remains the useful result even if best-effort cancellation fails. + timeout(); + return; + } catch (RuntimeException unexpected) { + result.completeExceptionally(unexpected); + return; + } + if (cancellation == null) { + result.completeExceptionally(new NullPointerException("Job canceler returned null")); + return; } - result.completeExceptionally(new RinApiException("job_timeout", "Rin job exceeded its deadline", 0, "")); + cancellation.whenComplete((canceledJob, cancelFailure) -> { + if (result.isDone()) return; + if (cancelFailure != null) { + Throwable cause = unwrap(cancelFailure); + if (cause instanceof RinException) timeout(); + else result.completeExceptionally(cause); + return; + } + if (!resolve(canceledJob)) timeout(); + }); return; } long delay = Math.min(interval.toNanos(), remaining); @@ -215,6 +282,96 @@ void poll() { return result; } + private static void validateJobIdentity( + Map job, + String resultKind, + String expectedJobId) { + Object responseJobId = job.get("job_id"); + if (!(responseJobId instanceof String id) || + !isProtocolIdentifier(id) || + !id.equals(expectedJobId)) { + throw new RinProtocolException( + "invalid_job", + "Rin returned a job with an invalid or mismatched job_id"); + } + if (resultKind.equals("proposal") && + (!isProtocolIdentifier(job.get("session_id")) || + !isProtocolIdentifier(job.get("request_id")))) { + throw new RinProtocolException( + "invalid_job", + "Rin returned a proposal job with invalid identity fields"); + } + if (resultKind.equals("generation") && !isProtocolIdentifier(job.get("request_id"))) { + throw new RinProtocolException( + "invalid_job", + "Rin returned a generation job with an invalid request_id"); + } + } + + private static boolean validProposalIdentity(Map proposal, Map job) { + return isProtocolIdentifier(proposal.get("id")) && + isProtocolIdentifier(proposal.get("actor_id")) && + Objects.equals(proposal.get("session_id"), job.get("session_id")) && + Objects.equals(proposal.get("request_id"), job.get("request_id")) && + isNonnegativeSignedInt64(proposal.get("tick")); + } + + private static boolean isProtocolIdentifier(Object value) { + if (!(value instanceof String text) || text.isEmpty() || text.length() > 96) return false; + char first = text.charAt(0); + if (!isAsciiLetterOrDigit(first)) return false; + for (int index = 1; index < text.length(); index++) { + char character = text.charAt(index); + if (!isAsciiLetterOrDigit(character) && character != '.' && character != '_' && character != '-') { + return false; + } + } + return true; + } + + private static boolean isAsciiLetterOrDigit(char value) { + return value >= 'a' && value <= 'z' || + value >= 'A' && value <= 'Z' || + value >= '0' && value <= '9'; + } + + private static boolean isNonnegativeSignedInt64(Object value) { + if (value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long) { + return ((Number) value).longValue() >= 0; + } + if (value instanceof BigInteger integer) { + return integer.signum() >= 0 && integer.bitLength() <= 63; + } + if (value instanceof BigDecimal decimal) { + try { + return decimal.scale() <= 0 && + decimal.toBigIntegerExact().signum() >= 0 && + decimal.toBigIntegerExact().bitLength() <= 63; + } catch (ArithmeticException ignored) { + return false; + } + } + if (value instanceof Double number) { + return Double.isFinite(number) && + number >= 0 && + number <= MAX_SAFE_DOUBLE_INTEGER && + number == Math.rint(number); + } + if (value instanceof Float number) { + return Float.isFinite(number) && + number >= 0 && + number <= MAX_SAFE_FLOAT_INTEGER && + number == Math.rint(number); + } + return false; + } + + private static boolean validGenerationContent(String content) { + return !content.isBlank() && + content.indexOf('\0') < 0 && + content.getBytes(StandardCharsets.UTF_8).length <= MAX_GENERATION_CONTENT_BYTES; + } + private CompletableFuture> post(String path, Map payload, int expectedStatus) { return request("POST", path, Objects.requireNonNull(payload, "payload"), Set.of(expectedStatus)); } diff --git a/sdk/java/test/io/github/sunrioa/rin/RinClientTest.java b/sdk/java/test/io/github/sunrioa/rin/RinClientTest.java index 99a5582..65f2677 100644 --- a/sdk/java/test/io/github/sunrioa/rin/RinClientTest.java +++ b/sdk/java/test/io/github/sunrioa/rin/RinClientTest.java @@ -29,9 +29,26 @@ public static void main(String[] args) throws Exception { Thread.currentThread().interrupt(); } } - byte[] body = mode[0].equals("oversized") - ? new byte[2048] - : "{}".getBytes(StandardCharsets.UTF_8); + byte[] body; + if (mode[0].equals("oversized")) { + body = new byte[2048]; + } else if (mode[0].equals("proposal-race")) { + body = (lastRequest[0].equals("DELETE") ? "proposal-succeeded" : "job-running") + .getBytes(StandardCharsets.UTF_8); + } else if (mode[0].equals("generation-race")) { + body = (lastRequest[0].equals("DELETE") ? "generation-succeeded" : "generation-running") + .getBytes(StandardCharsets.UTF_8); + } else if (mode[0].equals("terminal-cancel")) { + body = (lastRequest[0].equals("DELETE") ? "job-stale" : "job-running") + .getBytes(StandardCharsets.UTF_8); + } else if (mode[0].equals("crossed-get")) { + body = "job-crossed".getBytes(StandardCharsets.UTF_8); + } else if (mode[0].equals("malformed-delete")) { + body = (lastRequest[0].equals("DELETE") ? "proposal-malformed" : "job-running") + .getBytes(StandardCharsets.UTF_8); + } else { + body = "{}".getBytes(StandardCharsets.UTF_8); + } int status = (lastRequest[1].equals("/v1/jobs/propose") || lastRequest[1].equals("/v1/generation/jobs")) ? 202 : 200; exchange.sendResponseHeaders(status, body.length); exchange.getResponseBody().write(body); @@ -42,6 +59,54 @@ public static void main(String[] args) throws Exception { JsonCodec codec = new JsonCodec() { public String encode(Map value) { return "{}"; } public Map decodeObject(String json) { + if (json.equals("job-running")) { + return Map.of("ok", true, "data", proposalJob("running")); + } + if (json.equals("proposal-succeeded")) { + return Map.of( + "ok", true, + "data", proposalJob( + "succeeded", + Map.of( + "id", "proposal.race", + "session_id", "session.fixture", + "request_id", "request.fixture", + "actor_id", "actor.fixture", + "tick", 7L))); + } + if (json.equals("generation-running")) { + return Map.of("ok", true, "data", generationJob("running")); + } + if (json.equals("generation-succeeded")) { + return Map.of( + "ok", true, + "data", generationJob( + "succeeded", + Map.of("content", "finished at the deadline"))); + } + if (json.equals("job-stale")) { + return Map.of( + "ok", true, + "data", proposalJob( + "stale", + "error", + Map.of("code", "proposal_stale", "message", "World changed"))); + } + if (json.equals("job-crossed")) { + return Map.of("ok", true, "data", proposalJob("running", "job_id", "job.other")); + } + if (json.equals("proposal-malformed")) { + return Map.of( + "ok", true, + "data", proposalJob( + "succeeded", + Map.of( + "id", "proposal.race", + "session_id", "session.fixture", + "request_id", "request.fixture", + "actor_id", "actor.fixture", + "tick", Double.valueOf(1.5)))); + } return Map.of("ok", true, "data", Map.of("status", "ok")); } }; @@ -117,6 +182,65 @@ public Map decodeObject(String json) { require(cause instanceof RinTransportException, "wrong timeout error type"); require("transport_timeout".equals(((RinTransportException) cause).code()), "wrong timeout error code"); } + Thread.sleep(200); + + mode[0] = "proposal-race"; + Map proposalRace = client.waitForProposal( + "job.fixture", + Duration.ofMillis(50), + Duration.ofMillis(10)).join(); + Map proposal = (Map) proposalRace.get("proposal"); + require("proposal.race".equals(proposal.get("id")), "proposal cancellation race result was discarded"); + + mode[0] = "generation-race"; + Map generationRace = client.waitForGeneration( + "job.fixture", + Duration.ofMillis(50), + Duration.ofMillis(10)).join(); + Map generationResult = (Map) generationRace.get("result"); + require( + "finished at the deadline".equals(generationResult.get("content")), + "generation cancellation race result was discarded"); + + mode[0] = "terminal-cancel"; + try { + client.waitForProposal( + "job.fixture", + Duration.ofMillis(50), + Duration.ofMillis(10)).join(); + throw new AssertionError("terminal cancellation result was discarded"); + } catch (CompletionException expected) { + Throwable cause = rootCause(expected); + require(cause instanceof RinApiException, "terminal cancellation returned wrong error type"); + require( + "proposal_stale".equals(((RinApiException) cause).code()), + "terminal cancellation result became job_timeout"); + } + + mode[0] = "crossed-get"; + try { + client.waitForProposal("job.fixture").join(); + throw new AssertionError("crossed GET job identity was accepted"); + } catch (CompletionException expected) { + Throwable cause = rootCause(expected); + require(cause instanceof RinProtocolException, "crossed GET returned wrong error type"); + require("invalid_job".equals(((RinProtocolException) cause).code()), "crossed GET returned wrong error"); + } + + mode[0] = "malformed-delete"; + try { + client.waitForProposal( + "job.fixture", + Duration.ofMillis(50), + Duration.ofMillis(10)).join(); + throw new AssertionError("malformed DELETE proposal identity was accepted"); + } catch (CompletionException expected) { + Throwable cause = rootCause(expected); + require(cause instanceof RinProtocolException, "malformed DELETE returned wrong error type"); + require( + "invalid_job".equals(((RinProtocolException) cause).code()), + "malformed DELETE returned wrong error"); + } } finally { server.stop(0); } @@ -131,4 +255,36 @@ private static Throwable rootCause(Throwable error) { while (result instanceof CompletionException && result.getCause() != null) result = result.getCause(); return result; } + + private static Map proposalJob(String status) { + return proposalJob(status, Map.of()); + } + + private static Map proposalJob(String status, Map proposal) { + return proposalJob(status, "proposal", proposal); + } + + private static Map proposalJob(String status, String key, Object value) { + Map result = new java.util.LinkedHashMap<>(); + result.put("job_id", "job.fixture"); + result.put("session_id", "session.fixture"); + result.put("request_id", "request.fixture"); + result.put("status", status); + if (value instanceof Map map && !map.isEmpty()) result.put(key, value); + else if (!(value instanceof Map)) result.put(key, value); + return result; + } + + private static Map generationJob(String status, Map generationResult) { + Map result = new java.util.LinkedHashMap<>(); + result.put("job_id", "job.fixture"); + result.put("request_id", "generation.fixture"); + result.put("status", status); + if (!generationResult.isEmpty()) result.put("result", generationResult); + return result; + } + + private static Map generationJob(String status) { + return generationJob(status, Map.of()); + } } diff --git a/sdk/javascript/src/index.d.ts b/sdk/javascript/src/index.d.ts index 5854bdd..a2e7398 100644 --- a/sdk/javascript/src/index.d.ts +++ b/sdk/javascript/src/index.d.ts @@ -41,7 +41,9 @@ export class RinClient { submitGenerationJob(payload: RinObject): Promise; getGenerationJob(jobId: string): Promise; cancelGenerationJob(jobId: string): Promise; + /** Report an outcome the game already applied or rejected. */ commit(payload: RinObject): Promise; + /** Atomically report outcomes produced from one original world revision. */ commitBatch(payload: RinObject): Promise; setActorActivity(payload: RinObject): Promise; arbitrate(payload: RinObject): Promise; diff --git a/sdk/javascript/src/index.js b/sdk/javascript/src/index.js index 8f248bd..a4ccbcc 100644 --- a/sdk/javascript/src/index.js +++ b/sdk/javascript/src/index.js @@ -2,6 +2,8 @@ export const PROTOCOL_VERSION = "rin.protocol/v1"; export const DEFAULT_BASE_URL = "http://127.0.0.1:7374"; export const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; +const MAX_GENERATION_CONTENT_BYTES = 4 * 1024 * 1024; +const PROTOCOL_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/; const TERMINAL_JOB_STATES = new Set(["succeeded", "failed", "stale", "canceled"]); export class RinError extends Error { @@ -63,7 +65,9 @@ export class RinClient { submitGenerationJob(payload) { return this.request("POST", "/v1/generation/jobs", payload, [202]); } getGenerationJob(jobId) { return this.request("GET", `/v1/generation/jobs/${pathId(jobId)}`); } cancelGenerationJob(jobId) { return this.request("DELETE", `/v1/generation/jobs/${pathId(jobId)}`); } + // Report outcomes the game already applied or rejected; never use as execution authorization. commit(payload) { return this.post("/v1/action/commit", payload); } + // Atomically report outcomes whose proposals share one original world revision. commitBatch(payload) { return this.post("/v1/action/commit-batch", payload); } setActorActivity(payload) { return this.post("/v1/session/activity", payload); } arbitrate(payload) { return this.post("/v1/world/arbitrate", payload); } @@ -78,17 +82,17 @@ export class RinClient { return this.waitJob(jobId, this.getProposalJob.bind(this), this.cancelProposalJob.bind(this), { deadlineMs: 25000, ...options, - }); + }, "proposal"); } waitForGeneration(jobId, options = {}) { return this.waitJob(jobId, this.getGenerationJob.bind(this), this.cancelGenerationJob.bind(this), { deadlineMs: 45000, ...options, - }); + }, "generation"); } - async waitJob(jobId, getter, canceler, { deadlineMs, intervalMs = 100 }) { + async waitJob(jobId, getter, canceler, { deadlineMs, intervalMs = 100 }, resultKind = "") { if (!Number.isFinite(deadlineMs) || deadlineMs < 50 || deadlineMs > 300000 || !Number.isFinite(intervalMs) || intervalMs < 10 || intervalMs > 5000) { throw new RinConfigurationError("invalid_polling", "job deadline or interval is out of range"); @@ -96,21 +100,19 @@ export class RinClient { const expires = this.now() + deadlineMs; for (;;) { const job = await getter(jobId); - const status = String(job.status || ""); - if (status === "succeeded") return job; - if (TERMINAL_JOB_STATES.has(status)) { - const detail = isObject(job.error) ? job.error : {}; - throw new RinAPIError( - safeText(detail.code, 96) || `job_${status}`, - safeText(detail.message, 500) || `Rin job ended as ${status}`, - ); - } - if (status !== "queued" && status !== "running") { - throw new RinProtocolError("invalid_job", "Rin returned an unknown job status"); - } + const resolved = resolveJob(job, resultKind, jobId); + if (resolved) return resolved; const remaining = expires - this.now(); if (remaining <= 0) { - try { await canceler(jobId); } catch (error) { if (!(error instanceof RinError)) throw error; } + let canceledJob; + try { + canceledJob = await canceler(jobId); + } catch (error) { + if (!(error instanceof RinError)) throw error; + throw new RinAPIError("job_timeout", "Rin job exceeded its deadline"); + } + const canceledResult = resolveJob(canceledJob, resultKind, jobId); + if (canceledResult) return canceledResult; throw new RinAPIError("job_timeout", "Rin job exceeded its deadline"); } await this.sleep(Math.min(intervalMs, remaining)); @@ -297,6 +299,71 @@ function isObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } +function resolveJob(job, resultKind = "", expectedJobId = "") { + if (!isObject(job)) { + throw new RinProtocolError("invalid_job", "Rin returned an invalid job"); + } + validateJobIdentity(job, resultKind, expectedJobId); + if (typeof job.status !== "string") { + throw new RinProtocolError("invalid_job", "Rin returned an invalid job status"); + } + const status = job.status; + if (status === "succeeded") { + if (resultKind === "proposal") { + const proposal = job.proposal; + if (!isObject(proposal)) { + throw new RinProtocolError("invalid_job", "Successful proposal job did not include a proposal"); + } + if (!isProtocolIdentifier(proposal.id) || + !isProtocolIdentifier(proposal.actor_id) || + proposal.session_id !== job.session_id || + proposal.request_id !== job.request_id || + !Number.isSafeInteger(proposal.tick) || + proposal.tick < 0) { + throw new RinProtocolError("invalid_job", "Successful proposal job contained invalid identity fields"); + } + } + if (resultKind === "generation") { + const content = isObject(job.result) ? job.result.content : null; + if (typeof content !== "string" || + content.trim().length === 0 || + content.includes("\0") || + new TextEncoder().encode(content).byteLength > MAX_GENERATION_CONTENT_BYTES) { + throw new RinProtocolError("invalid_job", "Successful generation job did not include content"); + } + } + return job; + } + if (TERMINAL_JOB_STATES.has(status)) { + const detail = isObject(job.error) ? job.error : {}; + throw new RinAPIError( + safeText(detail.code, 96) || `job_${status}`, + safeText(detail.message, 500) || `Rin job ended as ${status}`, + ); + } + if (status !== "queued" && status !== "running") { + throw new RinProtocolError("invalid_job", "Rin returned an unknown job status"); + } + return null; +} + +function validateJobIdentity(job, resultKind, expectedJobId) { + if (!isProtocolIdentifier(job.job_id) || job.job_id !== expectedJobId) { + throw new RinProtocolError("invalid_job", "Rin returned a job with an invalid or mismatched job_id"); + } + if (resultKind === "proposal") { + if (!isProtocolIdentifier(job.session_id) || !isProtocolIdentifier(job.request_id)) { + throw new RinProtocolError("invalid_job", "Rin returned a proposal job with invalid identity fields"); + } + } else if (resultKind === "generation" && !isProtocolIdentifier(job.request_id)) { + throw new RinProtocolError("invalid_job", "Rin returned a generation job with an invalid request_id"); + } +} + +function isProtocolIdentifier(value) { + return typeof value === "string" && PROTOCOL_IDENTIFIER.test(value); +} + function safeText(value, maximum) { return String(value ?? "").replace(/\0/g, "").trim().split(/\s+/).filter(Boolean).join(" ").slice(0, maximum); } diff --git a/sdk/javascript/test/client.test.js b/sdk/javascript/test/client.test.js index 006d00b..0ded20f 100644 --- a/sdk/javascript/test/client.test.js +++ b/sdk/javascript/test/client.test.js @@ -18,6 +18,36 @@ function response(status, envelope, headers = {}) { }; } +function proposal(overrides = {}) { + return { + id: "proposal.fixture", + session_id: "session.fixture", + request_id: "request.fixture", + actor_id: "actor.fixture", + tick: 7, + ...overrides, + }; +} + +function proposalJob(status = "running", overrides = {}) { + return { + job_id: "job.fixture", + session_id: "session.fixture", + request_id: "request.fixture", + status, + ...overrides, + }; +} + +function generationJob(status = "running", overrides = {}) { + return { + job_id: "job.fixture", + request_id: "generation.fixture", + status, + ...overrides, + }; +} + test("all protocol routes use the expected method and bearer token", async () => { const requests = []; const fetch = async (url, options) => { @@ -127,3 +157,151 @@ test("API errors expose only the bounded protocol detail", async () => { return true; }); }); + +test("proposal completion returned by timeout cancellation wins the race", async () => { + let now = 0; + const client = new RinClient(undefined, { + now: () => now, + sleep: async (milliseconds) => { now += milliseconds; }, + fetch: async (url, options) => { + const path = new URL(url).pathname; + const data = options.method === "DELETE" + ? proposalJob("succeeded", { proposal: proposal({ id: "proposal.race" }) }) + : proposalJob(); + assert.equal(path, "/v1/jobs/job.fixture"); + return response(200, { ok: true, data }); + }, + }); + + const job = await client.waitForProposal("job.fixture", { deadlineMs: 50, intervalMs: 10 }); + + assert.equal(job.proposal.id, "proposal.race"); +}); + +test("generation completion returned by timeout cancellation wins the race", async () => { + let now = 0; + const client = new RinClient(undefined, { + now: () => now, + sleep: async (milliseconds) => { now += milliseconds; }, + fetch: async (url, options) => { + const path = new URL(url).pathname; + const data = options.method === "DELETE" + ? generationJob("succeeded", { result: { content: "finished at the deadline" } }) + : generationJob("queued"); + assert.equal(path, "/v1/generation/jobs/job.fixture"); + return response(200, { ok: true, data }); + }, + }); + + const job = await client.waitForGeneration("job.fixture", { deadlineMs: 50, intervalMs: 10 }); + + assert.equal(job.result.content, "finished at the deadline"); +}); + +test("timeout cancellation preserves terminal errors and validates raced success", async () => { + let now = 0; + let canceledData = proposalJob("stale", { + error: { code: "proposal_stale", message: "World changed" }, + }); + const client = new RinClient(undefined, { + now: () => now, + sleep: async (milliseconds) => { now += milliseconds; }, + fetch: async (_url, options) => response(200, { + ok: true, + data: options.method === "DELETE" ? canceledData : proposalJob(), + }), + }); + + await assert.rejects( + client.waitForProposal("job.fixture", { deadlineMs: 50, intervalMs: 10 }), + (error) => error instanceof RinAPIError && error.code === "proposal_stale", + ); + + now = 0; + canceledData = proposalJob("succeeded"); + await assert.rejects( + client.waitForProposal("job.fixture", { deadlineMs: 50, intervalMs: 10 }), + (error) => error instanceof RinProtocolError && error.code === "invalid_job", + ); +}); + +test("waiters reject crossed or malformed GET job identities", async () => { + let data = proposalJob("running", { job_id: "job.other" }); + const client = new RinClient(undefined, { + fetch: async () => response(200, { ok: true, data }), + }); + await assert.rejects( + client.waitForProposal("job.fixture"), + (error) => error instanceof RinProtocolError && error.code === "invalid_job", + ); + + for (const malformedProposal of [ + proposal({ session_id: "session.other" }), + proposal({ request_id: "request.other" }), + proposal({ tick: 1.5 }), + proposal({ tick: Number.MAX_SAFE_INTEGER + 1 }), + ]) { + data = proposalJob("succeeded", { proposal: malformedProposal }); + await assert.rejects( + client.waitForProposal("job.fixture"), + (error) => error instanceof RinProtocolError && error.code === "invalid_job", + ); + } + + data = generationJob("queued", { request_id: 42 }); + await assert.rejects( + client.waitForGeneration("job.fixture"), + (error) => error instanceof RinProtocolError && error.code === "invalid_job", + ); +}); + +test("waiters reject crossed or malformed timeout DELETE race results", async () => { + let now = 0; + let mode = "proposal"; + const client = new RinClient(undefined, { + maxResponseBytes: 8 * 1024 * 1024, + now: () => now, + sleep: async (milliseconds) => { now += milliseconds; }, + fetch: async (_url, options) => { + let data; + if (mode === "proposal") { + data = options.method === "DELETE" + ? proposalJob("succeeded", { job_id: "job.other", proposal: proposal() }) + : proposalJob(); + } else { + data = options.method === "DELETE" + ? generationJob("succeeded", { result: { content: "x".repeat(4 * 1024 * 1024 + 1) } }) + : generationJob(); + } + return response(200, { ok: true, data }); + }, + }); + + await assert.rejects( + client.waitForProposal("job.fixture", { deadlineMs: 50, intervalMs: 10 }), + (error) => error instanceof RinProtocolError && error.code === "invalid_job", + ); + + now = 0; + mode = "generation"; + await assert.rejects( + client.waitForGeneration("job.fixture", { deadlineMs: 50, intervalMs: 10 }), + (error) => error instanceof RinProtocolError && error.code === "invalid_job", + ); +}); + +test("a Rin error from timeout cancellation remains job_timeout", async () => { + let now = 0; + const client = new RinClient(undefined, { + now: () => now, + sleep: async (milliseconds) => { now += milliseconds; }, + fetch: async (_url, options) => options.method === "DELETE" + ? response(503, { ok: false, error: { code: "jobs_unavailable", message: "Unavailable" } }) + : response(200, { ok: true, data: proposalJob() }), + }); + + await assert.rejects( + client.waitForProposal("job.fixture", { deadlineMs: 50, intervalMs: 10 }), + (error) => error instanceof RinAPIError && error.code === "job_timeout", + ); +}); diff --git a/sdk/lua/rin.lua b/sdk/lua/rin.lua index 5b10841..7c25520 100644 --- a/sdk/lua/rin.lua +++ b/sdk/lua/rin.lua @@ -14,6 +14,9 @@ local terminal_job_states = { canceled = true, } +local max_generation_content_bytes = 4 * 1024 * 1024 +local max_safe_float_integer = 9007199254740991 + local function safe_text(value, maximum, fallback) local text = tostring(value or ""):gsub("%z", " "):gsub("%s+", " ") text = text:match("^%s*(.-)%s*$") or "" @@ -30,6 +33,80 @@ local function failure(code, message, status, field) } end +local function is_protocol_identifier(value) + if type(value) ~= "string" or #value < 1 or #value > 96 then return false end + for index = 1, #value do + local byte = value:byte(index) + local letter_or_digit = (byte >= 48 and byte <= 57) or + (byte >= 65 and byte <= 90) or (byte >= 97 and byte <= 122) + if not letter_or_digit and (index == 1 or (byte ~= 45 and byte ~= 46 and byte ~= 95)) then + return false + end + end + return true +end + +local function is_nonnegative_signed_int64(value) + if type(value) ~= "number" or value ~= value or value < 0 then return false end + if type(math.type) == "function" and math.type(value) == "integer" then return true end + return value <= max_safe_float_integer and value == math.floor(value) +end + +local function resolve_job(job, result_kind, expected_job_id) + if type(job) ~= "table" then + return nil, failure("invalid_job", "Rin returned an invalid job"), true + end + if not is_protocol_identifier(job.job_id) or job.job_id ~= expected_job_id then + return nil, failure("invalid_job", "Rin returned a job with an invalid or mismatched job_id"), true + end + if result_kind == "proposal" and + (not is_protocol_identifier(job.session_id) or not is_protocol_identifier(job.request_id)) then + return nil, failure("invalid_job", "Rin returned a proposal job with invalid identity fields"), true + end + if result_kind == "generation" and not is_protocol_identifier(job.request_id) then + return nil, failure("invalid_job", "Rin returned a generation job with an invalid request_id"), true + end + if type(job.status) ~= "string" then + return nil, failure("invalid_job", "Rin returned an invalid job status"), true + end + local status = job.status + if status == "succeeded" then + if result_kind == "proposal" then + local proposal = job.proposal + if type(proposal) ~= "table" or + not is_protocol_identifier(proposal.id) or + not is_protocol_identifier(proposal.actor_id) or + proposal.session_id ~= job.session_id or + proposal.request_id ~= job.request_id or + not is_nonnegative_signed_int64(proposal.tick) then + return nil, failure( + "invalid_job", + "Successful proposal job contained invalid identity fields" + ), true + end + end + if result_kind == "generation" then + local content = type(job.result) == "table" and job.result.content or nil + if type(content) ~= "string" or content:match("^%s*$") or + content:find("%z") or #content > max_generation_content_bytes then + return nil, failure( + "invalid_job", + "Successful generation job did not include bounded content" + ), true + end + end + return job, nil, true + end + if terminal_job_states[status] then + local detail = type(job.error) == "table" and job.error or {} + return nil, failure(detail.code or ("job_" .. status), detail.message or ("Rin job ended as " .. status)), true + end + if status ~= "queued" and status ~= "running" then + return nil, failure("invalid_job", "Rin returned an unknown job status"), true + end + return nil, nil, false +end + local function validate_token(value) local token = tostring(value or "") if #token > 4096 or token:find("[%z\r\n]") or token:match("^%s") or token:match("%s$") then @@ -274,7 +351,9 @@ function Client:cancel_generation_job(job_id, callback) if not id then callback(nil, err); return end self:_request("DELETE", "/v1/generation/jobs/" .. id, nil, 200, callback) end +-- Report outcomes the game already applied or rejected; this does not execute them. function Client:commit(payload, callback) self:_post("/v1/action/commit", payload, 200, callback) end +-- Atomically report outcomes produced from one original world revision. function Client:commit_batch(payload, callback) self:_post("/v1/action/commit-batch", payload, 200, callback) end function Client:set_actor_activity(payload, callback) self:_post("/v1/session/activity", payload, 200, callback) end function Client:arbitrate(payload, callback) self:_post("/v1/world/arbitrate", payload, 200, callback) end @@ -285,7 +364,7 @@ function Client:timeline(payload, callback) self:_post("/v1/session/timeline", p function Client:replay(payload, callback) self:_post("/v1/session/replay", payload, 200, callback) end function Client:due_agents(payload, callback) self:_post("/v1/scheduler/due", payload, 200, callback) end -function Client:_wait_job(job_id, getter, canceler, options, callback) +function Client:_wait_job(job_id, getter, canceler, options, callback, result_kind) options = options or {} local deadline = tonumber(options.deadline or 25) local interval = tonumber(options.interval or 0.1) @@ -303,20 +382,22 @@ function Client:_wait_job(job_id, getter, canceler, options, callback) poll = function() getter(self, job_id, function(job, err) if err then callback(nil, err); return end - local status = tostring(job.status or "") - if status == "succeeded" then callback(job, nil); return end - if terminal_job_states[status] then - local detail = type(job.error) == "table" and job.error or {} - callback(nil, failure(detail.code or ("job_" .. status), detail.message or ("Rin job ended as " .. status))) - return - end - if status ~= "queued" and status ~= "running" then - callback(nil, failure("invalid_job", "Rin returned an unknown job status")) - return - end + local resolved, job_error, terminal = resolve_job(job, result_kind, job_id) + if terminal then callback(resolved, job_error); return end if self.now() >= expires then - canceler(self, job_id, function() end) - callback(nil, failure("job_timeout", "Rin job exceeded its deadline")) + canceler(self, job_id, function(canceled_job, cancel_error) + if cancel_error then + callback(nil, failure("job_timeout", "Rin job exceeded its deadline")) + return + end + local canceled_result, canceled_error, canceled_terminal = + resolve_job(canceled_job, result_kind, job_id) + if canceled_terminal then + callback(canceled_result, canceled_error) + else + callback(nil, failure("job_timeout", "Rin job exceeded its deadline")) + end + end) return end self.schedule(interval, poll) @@ -326,14 +407,14 @@ function Client:_wait_job(job_id, getter, canceler, options, callback) end function Client:wait_for_proposal(job_id, options, callback) - self:_wait_job(job_id, Client.get_proposal_job, Client.cancel_proposal_job, options, callback) + self:_wait_job(job_id, Client.get_proposal_job, Client.cancel_proposal_job, options, callback, "proposal") end function Client:wait_for_generation(job_id, options, callback) local configured = {} for key, value in pairs(options or {}) do configured[key] = value end if configured.deadline == nil then configured.deadline = 45 end - self:_wait_job(job_id, Client.get_generation_job, Client.cancel_generation_job, configured, callback) + self:_wait_job(job_id, Client.get_generation_job, Client.cancel_generation_job, configured, callback, "generation") end return rin diff --git a/sdk/lua/test_client.lua b/sdk/lua/test_client.lua index 6634e97..b85f2ab 100644 --- a/sdk/lua/test_client.lua +++ b/sdk/lua/test_client.lua @@ -50,6 +50,39 @@ client:get_proposal_job(string.char(228, 189, 156, 228, 184, 154), function(data assert(not data and err.code == "invalid_identifier") end) +local function proposal(overrides) + local value = { + id = "proposal.fixture", + session_id = "session.fixture", + request_id = "request.fixture", + actor_id = "actor.fixture", + tick = 7, + } + for key, field in pairs(overrides or {}) do value[key] = field end + return value +end + +local function proposal_job(status, overrides) + local value = { + job_id = "job.fixture", + session_id = "session.fixture", + request_id = "request.fixture", + status = status or "running", + } + for key, field in pairs(overrides or {}) do value[key] = field end + return value +end + +local function generation_job(status, overrides) + local value = { + job_id = "job.fixture", + request_id = "generation.fixture", + status = status or "running", + } + for key, field in pairs(overrides or {}) do value[key] = field end + return value +end + local remote, remote_error = rin.new({ base_url = "http://models.example", token = "fixture", @@ -67,7 +100,7 @@ local polling_client = assert(rin.new({ callback({ status = 200, body = "{}", headers = {} }) end, json_encode = function() return "{}" end, - json_decode = function() return { ok = true, data = { status = "running" } } end, + json_decode = function() return { ok = true, data = proposal_job() } end, schedule = function(seconds, callback) clock = clock + seconds; callback() end, now = function() return clock end, })) @@ -76,4 +109,73 @@ polling_client:wait_for_proposal("job.fixture", { deadline = 0.05, interval = 0. end) assert(canceled, "timed-out job was not canceled") +local function make_race_client(cancel_data, result_kind, get_data) + local race_clock = 0 + local method = "GET" + local race_client = assert(rin.new({ + http_fetch = function(request, callback) + method = request.method + callback({ status = 200, body = "{}", headers = {} }) + end, + json_encode = function() return "{}" end, + json_decode = function() + return { + ok = true, + data = method == "DELETE" and cancel_data or + get_data or (result_kind == "generation" and generation_job() or proposal_job()), + } + end, + schedule = function(seconds, callback) + race_clock = race_clock + seconds + callback() + end, + now = function() return race_clock end, + })) + return race_client +end + +local proposal_race = make_race_client(proposal_job("succeeded", { + proposal = proposal({ id = "proposal.race" }), +}), "proposal") +proposal_race:wait_for_proposal("job.fixture", { deadline = 0.05, interval = 0.01 }, function(data, err) + assert(data and not err) + assert(data.proposal.id == "proposal.race", "proposal cancellation race result was discarded") +end) + +local generation_race = make_race_client(generation_job("succeeded", { + result = { content = "finished at the deadline" }, +}), "generation") +generation_race:wait_for_generation("job.fixture", { deadline = 0.05, interval = 0.01 }, function(data, err) + assert(data and not err) + assert(data.result.content == "finished at the deadline", "generation cancellation race result was discarded") +end) + +local terminal_cancel = make_race_client(proposal_job("stale", { + error = { code = "proposal_stale", message = "World changed" }, +}), "proposal") +terminal_cancel:wait_for_proposal("job.fixture", { deadline = 0.05, interval = 0.01 }, function(data, err) + assert(not data and err.code == "proposal_stale", "terminal cancellation result became job_timeout") +end) + +local invalid_race = make_race_client(proposal_job("succeeded"), "proposal") +invalid_race:wait_for_proposal("job.fixture", { deadline = 0.05, interval = 0.01 }, function(data, err) + assert(not data and err.code == "invalid_job", "successful proposal without payload was accepted") +end) + +local crossed_get = make_race_client( + proposal_job("canceled"), + "proposal", + proposal_job("running", { job_id = "job.other" }) +) +crossed_get:wait_for_proposal("job.fixture", { deadline = 0.05, interval = 0.01 }, function(data, err) + assert(not data and err.code == "invalid_job", "crossed GET job identity was accepted") +end) + +local malformed_delete = make_race_client(proposal_job("succeeded", { + proposal = proposal({ tick = 1.5 }), +}), "proposal") +malformed_delete:wait_for_proposal("job.fixture", { deadline = 0.05, interval = 0.01 }, function(data, err) + assert(not data and err.code == "invalid_job", "malformed DELETE proposal identity was accepted") +end) + print("Rin Lua SDK tests passed") diff --git a/sdk/python/src/rin_sdk/client.py b/sdk/python/src/rin_sdk/client.py index a431599..b308354 100644 --- a/sdk/python/src/rin_sdk/client.py +++ b/sdk/python/src/rin_sdk/client.py @@ -4,6 +4,7 @@ import ipaddress import json +import re import time from typing import Any, Callable, Dict, Optional, Sequence, Tuple from urllib.error import HTTPError, URLError @@ -14,6 +15,9 @@ PROTOCOL_VERSION = "rin.protocol/v1" DEFAULT_BASE_URL = "http://127.0.0.1:7374" DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024 +_MAX_GENERATION_CONTENT_BYTES = 4 * 1024 * 1024 +_MAX_SIGNED_INT64 = (1 << 63) - 1 +_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$") _TERMINAL_JOB_STATES = frozenset(("succeeded", "failed", "stale", "canceled")) @@ -102,9 +106,11 @@ def cancel_generation_job(self, job_id: str) -> Dict[str, Any]: return self._request("DELETE", "/v1/generation/jobs/" + _path_id(job_id)) def commit(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """Report a game-applied or rejected outcome; this does not execute it.""" return self._post("/v1/action/commit", payload) def commit_batch(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """Atomically report game outcomes produced from one world revision.""" return self._post("/v1/action/commit-batch", payload) def set_actor_activity(self, payload: Dict[str, Any]) -> Dict[str, Any]: @@ -132,10 +138,24 @@ def due_agents(self, payload: Dict[str, Any]) -> Dict[str, Any]: return self._post("/v1/scheduler/due", payload) def wait_for_proposal(self, job_id: str, *, deadline: float = 25.0, interval: float = 0.1) -> Dict[str, Any]: - return self._wait_job(job_id, self.get_proposal_job, self.cancel_proposal_job, deadline, interval) + return self._wait_job( + job_id, + self.get_proposal_job, + self.cancel_proposal_job, + deadline, + interval, + "proposal", + ) def wait_for_generation(self, job_id: str, *, deadline: float = 45.0, interval: float = 0.1) -> Dict[str, Any]: - return self._wait_job(job_id, self.get_generation_job, self.cancel_generation_job, deadline, interval) + return self._wait_job( + job_id, + self.get_generation_job, + self.cancel_generation_job, + deadline, + interval, + "generation", + ) def _wait_job( self, @@ -144,32 +164,65 @@ def _wait_job( canceler: Callable[[str], Dict[str, Any]], deadline: float, interval: float, + result_kind: str, ) -> Dict[str, Any]: if not 0.05 <= deadline <= 300.0 or not 0.01 <= interval <= 5.0: raise RinConfigurationError("invalid_polling", "job deadline or interval is out of range") expires = self._clock() + deadline while True: job = getter(job_id) - status = str(job.get("status", "")) - if status == "succeeded": - return job - if status in _TERMINAL_JOB_STATES: - detail = job.get("error") if isinstance(job.get("error"), dict) else {} - raise RinAPIError( - _safe_text(detail.get("code"), 96) or "job_" + status, - _safe_text(detail.get("message"), 500) or "Rin job ended as " + status, - ) - if status not in ("queued", "running"): - raise RinProtocolError("invalid_job", "Rin returned an unknown job status") + resolved = self._resolve_job(job, result_kind, job_id) + if resolved is not None: + return resolved remaining = expires - self._clock() if remaining <= 0: try: - canceler(job_id) + canceled_job = canceler(job_id) except RinError: - pass + raise RinAPIError("job_timeout", "Rin job exceeded its deadline") from None + resolved = self._resolve_job(canceled_job, result_kind, job_id) + if resolved is not None: + return resolved raise RinAPIError("job_timeout", "Rin job exceeded its deadline") self._sleeper(min(interval, remaining)) + @staticmethod + def _resolve_job(job: Dict[str, Any], result_kind: str, expected_job_id: str) -> Optional[Dict[str, Any]]: + if not isinstance(job, dict): + raise RinProtocolError("invalid_job", "Rin returned an invalid job") + _validate_job_identity(job, result_kind, expected_job_id) + status = job.get("status") + if not isinstance(status, str): + raise RinProtocolError("invalid_job", "Rin returned an invalid job status") + if status == "succeeded": + if result_kind == "proposal": + proposal = job.get("proposal") + if not isinstance(proposal, dict): + raise RinProtocolError("invalid_job", "Successful proposal job did not include a proposal") + if ( + not _is_protocol_id(proposal.get("id")) + or not _is_protocol_id(proposal.get("actor_id")) + or proposal.get("session_id") != job["session_id"] + or proposal.get("request_id") != job["request_id"] + or not _is_nonnegative_int64(proposal.get("tick")) + ): + raise RinProtocolError("invalid_job", "Successful proposal job contained invalid identity fields") + if result_kind == "generation": + result = job.get("result") + content = result.get("content") if isinstance(result, dict) else None + if not _is_bounded_generation_content(content): + raise RinProtocolError("invalid_job", "Successful generation job did not include content") + return job + if status in _TERMINAL_JOB_STATES: + detail = job.get("error") if isinstance(job.get("error"), dict) else {} + raise RinAPIError( + _safe_text(detail.get("code"), 96) or "job_" + status, + _safe_text(detail.get("message"), 500) or "Rin job ended as " + status, + ) + if status not in ("queued", "running"): + raise RinProtocolError("invalid_job", "Rin returned an unknown job status") + return None + def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: return self._request("POST", path, payload) @@ -314,5 +367,34 @@ def _path_id(value: str) -> str: return quote(text, safe="._-") +def _validate_job_identity(job: Dict[str, Any], result_kind: str, expected_job_id: str) -> None: + response_job_id = job.get("job_id") + if not _is_protocol_id(response_job_id) or response_job_id != expected_job_id: + raise RinProtocolError("invalid_job", "Rin returned a job with an invalid or mismatched job_id") + if result_kind == "proposal": + if not _is_protocol_id(job.get("session_id")) or not _is_protocol_id(job.get("request_id")): + raise RinProtocolError("invalid_job", "Rin returned a proposal job with invalid identity fields") + elif result_kind == "generation": + if not _is_protocol_id(job.get("request_id")): + raise RinProtocolError("invalid_job", "Rin returned a generation job with an invalid request_id") + + +def _is_protocol_id(value: Any) -> bool: + return isinstance(value, str) and _IDENTIFIER.fullmatch(value) is not None + + +def _is_nonnegative_int64(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= _MAX_SIGNED_INT64 + + +def _is_bounded_generation_content(value: Any) -> bool: + if not isinstance(value, str) or not value.strip() or "\x00" in value: + return False + try: + return len(value.encode("utf-8")) <= _MAX_GENERATION_CONTENT_BYTES + except UnicodeEncodeError: + return False + + def _safe_text(value: Any, maximum: int) -> str: return " ".join(str(value or "").replace("\x00", "").split())[:maximum] diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 7525ce9..f26d6ef 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -51,6 +51,56 @@ def open(self, request, timeout): return _Response(status, {"ok": True, "data": {"status": "ok", "job_id": "job.fixture"}}) +class _AdvancingClock: + def __init__(self): + self.value = 0.0 + + def now(self): + return self.value + + def sleep(self, seconds): + self.value += seconds + + +def _proposal_job(status="running", *, job_id="job.fixture", proposal=None, error=None): + job = { + "job_id": job_id, + "session_id": "session.fixture", + "request_id": "request.fixture", + "status": status, + } + if proposal is not None: + job["proposal"] = proposal + if error is not None: + job["error"] = error + return job + + +def _proposal(**overrides): + proposal = { + "id": "proposal.fixture", + "session_id": "session.fixture", + "request_id": "request.fixture", + "actor_id": "actor.fixture", + "tick": 7, + } + proposal.update(overrides) + return proposal + + +def _generation_job(status="running", *, job_id="job.fixture", result=None, error=None): + job = { + "job_id": job_id, + "request_id": "generation.fixture", + "status": status, + } + if result is not None: + job["result"] = result + if error is not None: + job["error"] = error + return job + + class RinClientTests(unittest.TestCase): def test_routes_and_token(self): client = RinClient(token="fixture") @@ -147,6 +197,116 @@ def open(self, request, timeout): client.health() self.assertEqual(caught.exception.code, "redirect_rejected") + def test_proposal_that_finishes_during_timeout_cancellation_is_returned(self): + clock = _AdvancingClock() + client = RinClient(clock=clock.now, sleeper=clock.sleep) + client.get_proposal_job = lambda _job_id: _proposal_job() + client.cancel_proposal_job = lambda _job_id: _proposal_job( + "succeeded", + proposal=_proposal(), + ) + + job = client.wait_for_proposal("job.fixture", deadline=0.05, interval=0.01) + + self.assertEqual(job["status"], "succeeded") + self.assertEqual(job["proposal"]["id"], "proposal.fixture") + + def test_generation_that_finishes_during_timeout_cancellation_is_returned(self): + clock = _AdvancingClock() + client = RinClient(clock=clock.now, sleeper=clock.sleep) + client.get_generation_job = lambda _job_id: _generation_job("queued") + client.cancel_generation_job = lambda _job_id: _generation_job( + "succeeded", + result={"content": "finished at the deadline"}, + ) + + job = client.wait_for_generation("job.fixture", deadline=0.05, interval=0.01) + + self.assertEqual(job["result"]["content"], "finished at the deadline") + + def test_timeout_uses_terminal_cancel_error_and_validates_success_payload(self): + clock = _AdvancingClock() + client = RinClient(clock=clock.now, sleeper=clock.sleep) + client.get_proposal_job = lambda _job_id: _proposal_job() + client.cancel_proposal_job = lambda _job_id: _proposal_job( + "stale", + error={"code": "proposal_stale", "message": "World changed"}, + ) + with self.assertRaises(RinAPIError) as caught: + client.wait_for_proposal("job.fixture", deadline=0.05, interval=0.01) + self.assertEqual(caught.exception.code, "proposal_stale") + + clock.value = 0.0 + client.cancel_proposal_job = lambda _job_id: _proposal_job("succeeded") + with self.assertRaises(RinProtocolError) as caught: + client.wait_for_proposal("job.fixture", deadline=0.05, interval=0.01) + self.assertEqual(caught.exception.code, "invalid_job") + + def test_wait_rejects_crossed_or_malformed_get_identity(self): + client = RinClient() + client.get_proposal_job = lambda _job_id: _proposal_job(job_id="job.other") + with self.assertRaises(RinProtocolError) as caught: + client.wait_for_proposal("job.fixture") + self.assertEqual(caught.exception.code, "invalid_job") + + for malformed in ( + _proposal(session_id="session.other"), + _proposal(request_id="request.other"), + _proposal(tick=1.5), + _proposal(tick=1 << 63), + ): + with self.subTest(proposal=malformed): + client.get_proposal_job = lambda _job_id, value=malformed: _proposal_job( + "succeeded", + proposal=value, + ) + with self.assertRaises(RinProtocolError) as caught: + client.wait_for_proposal("job.fixture") + self.assertEqual(caught.exception.code, "invalid_job") + + malformed_generation = _generation_job() + malformed_generation["request_id"] = 42 + client.get_generation_job = lambda _job_id: malformed_generation + with self.assertRaises(RinProtocolError) as caught: + client.wait_for_generation("job.fixture") + self.assertEqual(caught.exception.code, "invalid_job") + + def test_wait_rejects_crossed_or_malformed_timeout_delete_identity(self): + clock = _AdvancingClock() + client = RinClient(clock=clock.now, sleeper=clock.sleep) + client.get_proposal_job = lambda _job_id: _proposal_job() + client.cancel_proposal_job = lambda _job_id: _proposal_job( + "succeeded", + job_id="job.other", + proposal=_proposal(), + ) + with self.assertRaises(RinProtocolError) as caught: + client.wait_for_proposal("job.fixture", deadline=0.05, interval=0.01) + self.assertEqual(caught.exception.code, "invalid_job") + + clock.value = 0.0 + client.get_generation_job = lambda _job_id: _generation_job() + client.cancel_generation_job = lambda _job_id: _generation_job( + "succeeded", + result={"content": "x" * (4 * 1024 * 1024 + 1)}, + ) + with self.assertRaises(RinProtocolError) as caught: + client.wait_for_generation("job.fixture", deadline=0.05, interval=0.01) + self.assertEqual(caught.exception.code, "invalid_job") + + def test_cancel_api_error_remains_job_timeout(self): + clock = _AdvancingClock() + client = RinClient(clock=clock.now, sleeper=clock.sleep) + client.get_generation_job = lambda _job_id: _generation_job() + + def fail_cancel(_job_id): + raise RinAPIError("jobs_unavailable", "Unavailable") + + client.cancel_generation_job = fail_cancel + with self.assertRaises(RinAPIError) as caught: + client.wait_for_generation("job.fixture", deadline=0.05, interval=0.01) + self.assertEqual(caught.exception.code, "job_timeout") + if __name__ == "__main__": unittest.main() diff --git a/store/file.go b/store/file.go index 80e8eae..29f4abc 100644 --- a/store/file.go +++ b/store/file.go @@ -21,6 +21,8 @@ import ( var safeID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$`) var safeHash = regexp.MustCompile(`^[0-9a-f]{64}$`) +const maxEventRecordBytes = 64 * 1024 * 1024 + type File struct { root string mu sync.Mutex @@ -49,6 +51,12 @@ func (s *File) Create(sessionID string, event protocol.EventRecord) error { } if err := os.Mkdir(directory, 0o700); err != nil { if errors.Is(err, os.ErrExist) { + path := filepath.Join(directory, "events.jsonl") + events, loadErr := readEventFile(path) + if loadErr == nil && len(events) == 1 && + rinruntime.EventRecordsExactlyEqual(events[0], event) { + return syncExistingFile(path) + } return rinruntime.ErrConflict } return err @@ -76,7 +84,22 @@ func (s *File) Append(sessionID string, event protocol.EventRecord) error { if err != nil { return err } - file, err := os.OpenFile(filepath.Join(directory, "events.jsonl"), os.O_WRONLY|os.O_APPEND, 0o600) + path := filepath.Join(directory, "events.jsonl") + last, err := readLastEvent(path) + if err != nil { + return err + } + if rinruntime.EventRecordsExactlyEqual(event, last) { + return syncExistingFile(path) + } + if event.Sequence != last.Sequence+1 || event.PrevHash != last.Hash { + return rinruntime.ErrConflict + } + info, err := os.Stat(path) + if err != nil { + return err + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { if errors.Is(err, os.ErrNotExist) { return rinruntime.ErrNotFound @@ -84,10 +107,15 @@ func (s *File) Append(sessionID string, event protocol.EventRecord) error { return err } err = writeEvent(file, event) - closeErr := file.Close() if err != nil { - return err + rollbackErr := file.Truncate(info.Size()) + if rollbackErr == nil { + rollbackErr = file.Sync() + } + closeErr := file.Close() + return errors.Join(err, rollbackErr, closeErr) } + closeErr := file.Close() return closeErr } @@ -110,7 +138,11 @@ func (s *File) Load(sessionID string) ([]protocol.EventRecord, error) { if err != nil { return nil, err } - file, err := os.Open(filepath.Join(directory, "events.jsonl")) + return readEventFile(filepath.Join(directory, "events.jsonl")) +} + +func readEventFile(path string) ([]protocol.EventRecord, error) { + file, err := os.Open(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, rinruntime.ErrNotFound @@ -118,8 +150,22 @@ func (s *File) Load(sessionID string) ([]protocol.EventRecord, error) { return nil, err } defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, err + } + if info.Size() == 0 { + return nil, rinruntime.ErrCorruptLog + } + var final [1]byte + if _, err := file.ReadAt(final[:], info.Size()-1); err != nil { + return nil, err + } + if final[0] != '\n' { + return nil, fmt.Errorf("%w: event log has an incomplete tail", rinruntime.ErrCorruptLog) + } scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 64*1024), 64*1024*1024) + scanner.Buffer(make([]byte, 64*1024), maxEventRecordBytes) events := make([]protocol.EventRecord, 0) for line := 1; scanner.Scan(); line++ { decoder := json.NewDecoder(bytes.NewReader(scanner.Bytes())) @@ -142,6 +188,83 @@ func (s *File) Load(sessionID string) ([]protocol.EventRecord, error) { return events, nil } +func readLastEvent(path string) (protocol.EventRecord, error) { + file, err := os.Open(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return protocol.EventRecord{}, rinruntime.ErrNotFound + } + return protocol.EventRecord{}, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return protocol.EventRecord{}, err + } + size := info.Size() + if size == 0 { + return protocol.EventRecord{}, rinruntime.ErrCorruptLog + } + var final [1]byte + if _, err := file.ReadAt(final[:], size-1); err != nil { + return protocol.EventRecord{}, err + } + if final[0] != '\n' { + return protocol.EventRecord{}, fmt.Errorf("%w: event log has an incomplete tail", rinruntime.ErrCorruptLog) + } + + lineEnd := size - 1 + lineStart := int64(0) + searchEnd := lineEnd + buffer := make([]byte, 64*1024) + for searchEnd > 0 { + chunkStart := searchEnd - int64(len(buffer)) + if chunkStart < 0 { + chunkStart = 0 + } + chunk := buffer[:searchEnd-chunkStart] + if _, err := file.ReadAt(chunk, chunkStart); err != nil { + return protocol.EventRecord{}, err + } + if index := bytes.LastIndexByte(chunk, '\n'); index >= 0 { + lineStart = chunkStart + int64(index) + 1 + break + } + searchEnd = chunkStart + if lineEnd-searchEnd > maxEventRecordBytes { + return protocol.EventRecord{}, fmt.Errorf("%w: event tail exceeds %d bytes", rinruntime.ErrCorruptLog, maxEventRecordBytes) + } + } + lineLength := lineEnd - lineStart + if lineLength <= 0 || lineLength > maxEventRecordBytes { + return protocol.EventRecord{}, fmt.Errorf("%w: invalid event tail length", rinruntime.ErrCorruptLog) + } + line := make([]byte, lineLength) + if _, err := file.ReadAt(line, lineStart); err != nil { + return protocol.EventRecord{}, err + } + decoder := json.NewDecoder(bytes.NewReader(line)) + decoder.DisallowUnknownFields() + var event protocol.EventRecord + if err := decoder.Decode(&event); err != nil { + return protocol.EventRecord{}, fmt.Errorf("decode event tail: %w", err) + } + if err := ensureEOF(decoder); err != nil { + return protocol.EventRecord{}, fmt.Errorf("decode event tail: %w", err) + } + return event, nil +} + +func syncExistingFile(path string) error { + file, err := os.OpenFile(path, os.O_RDWR, 0o600) + if err != nil { + return err + } + syncErr := file.Sync() + closeErr := file.Close() + return errors.Join(syncErr, closeErr) +} + func (s *File) ListSessions() ([]string, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/store/file_test.go b/store/file_test.go index ae84c35..92fc9ee 100644 --- a/store/file_test.go +++ b/store/file_test.go @@ -1,8 +1,10 @@ package store_test import ( + "errors" "os" "path/filepath" + "reflect" "strings" "testing" @@ -26,6 +28,25 @@ func TestFileStoreReplaysAndDetectsTamper(t *testing.T) { if _, err := engine.CreateSession(request); err != nil { t.Fatal(err) } + createdEvents, err := fileStore.Load(request.SessionID) + if err != nil { + t.Fatal(err) + } + if err := fileStore.Create(request.SessionID, createdEvents[0]); err != nil { + t.Fatalf("exact create retry should confirm durability: %v", err) + } + for name, nonExact := range nonExactEventRetries(createdEvents[0]) { + t.Run("create-"+name, func(t *testing.T) { + if err := fileStore.Create(request.SessionID, nonExact); !errors.Is(err, rinruntime.ErrConflict) { + t.Fatalf("non-exact create retry should conflict: %v", err) + } + }) + } + differentCreate := createdEvents[0] + differentCreate.Hash = strings.Repeat("c", 64) + if err := fileStore.Create(request.SessionID, differentCreate); !errors.Is(err, rinruntime.ErrConflict) { + t.Fatalf("different create event should conflict: %v", err) + } if _, err := engine.Observe(protocol.ObserveRequest{ ProtocolVersion: protocol.Version, SessionID: request.SessionID, @@ -73,6 +94,172 @@ func TestFileStoreRejectsTraversal(t *testing.T) { } } +func TestFileStoreAppendIsIdempotentAndChecksExpectedHead(t *testing.T) { + fileStore, err := store.OpenFile(t.TempDir()) + if err != nil { + t.Fatal(err) + } + engine, err := rinruntime.Open(fileStore, policy.Deterministic{}) + if err != nil { + t.Fatal(err) + } + request := fileCreateRequest() + if _, err := engine.CreateSession(request); err != nil { + t.Fatal(err) + } + if _, err := engine.Observe(protocol.ObserveRequest{ + ProtocolVersion: protocol.Version, + SessionID: request.SessionID, + RequestID: "observe.file-idempotent", + EventID: "event.file-idempotent", + Tick: 1, + ObserverIDs: []string{"npc.one"}, + Source: "game", + Kind: "world", + Summary: "A durable bell rang.", + Importance: 2, + }); err != nil { + t.Fatal(err) + } + events, err := fileStore.Load(request.SessionID) + if err != nil { + t.Fatal(err) + } + tail := events[len(events)-1] + if err := fileStore.Append(request.SessionID, tail); err != nil { + t.Fatalf("exact append retry should be idempotent: %v", err) + } + for name, nonExact := range nonExactEventRetries(tail) { + t.Run("append-"+name, func(t *testing.T) { + if err := fileStore.Append(request.SessionID, nonExact); !errors.Is(err, rinruntime.ErrConflict) { + t.Fatalf("non-exact append retry should conflict: %v", err) + } + }) + } + afterRetry, err := fileStore.Load(request.SessionID) + if err != nil { + t.Fatal(err) + } + if len(afterRetry) != len(events) { + t.Fatalf("exact append retry added a duplicate line: before=%d after=%d", len(events), len(afterRetry)) + } + if !reflect.DeepEqual(afterRetry, events) { + t.Fatalf("exact append retry changed the log:\nbefore=%+v\nafter=%+v", events, afterRetry) + } + baseline := append([]protocol.EventRecord(nil), afterRetry...) + conflict := tail + conflict.Hash = strings.Repeat("f", 64) + if err := fileStore.Append(request.SessionID, conflict); !errors.Is(err, rinruntime.ErrConflict) { + t.Fatalf("different event at the current sequence should conflict: %v", err) + } + wrongHead := tail + wrongHead.Sequence++ + wrongHead.Hash = strings.Repeat("e", 64) + wrongHead.PrevHash = strings.Repeat("d", 64) + if err := fileStore.Append(request.SessionID, wrongHead); !errors.Is(err, rinruntime.ErrConflict) { + t.Fatalf("append with an unexpected previous hash should conflict: %v", err) + } + afterConflicts, err := fileStore.Load(request.SessionID) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(afterConflicts, baseline) { + t.Fatalf("conflicting appends mutated the file log:\nbefore=%+v\nafter=%+v", baseline, afterConflicts) + } +} + +func TestFileStoreLoadRejectsIncompleteTail(t *testing.T) { + root := t.TempDir() + fileStore, err := store.OpenFile(root) + if err != nil { + t.Fatal(err) + } + engine, err := rinruntime.Open(fileStore, policy.Deterministic{}) + if err != nil { + t.Fatal(err) + } + request := fileCreateRequest() + if _, err := engine.CreateSession(request); err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "sessions", request.SessionID, "events.jsonl") + file, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + t.Fatal(err) + } + if _, err := file.WriteString(`{"sequence":2`); err != nil { + _ = file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + if _, err := fileStore.Load(request.SessionID); !errors.Is(err, rinruntime.ErrCorruptLog) { + t.Fatalf("Load should reject an incomplete tail as corruption, got %v", err) + } +} + +func TestMemoryStoreAppendIsIdempotentAndChecksExpectedHead(t *testing.T) { + memoryStore := store.NewMemory() + engine, err := rinruntime.Open(memoryStore, policy.Deterministic{}) + if err != nil { + t.Fatal(err) + } + request := fileCreateRequest() + if _, err := engine.CreateSession(request); err != nil { + t.Fatal(err) + } + events, err := memoryStore.Load(request.SessionID) + if err != nil { + t.Fatal(err) + } + tail := events[len(events)-1] + if err := memoryStore.Create(request.SessionID, tail); err != nil { + t.Fatalf("exact create retry should confirm durability: %v", err) + } + for name, nonExact := range nonExactEventRetries(tail) { + t.Run("create-"+name, func(t *testing.T) { + if err := memoryStore.Create(request.SessionID, nonExact); !errors.Is(err, rinruntime.ErrConflict) { + t.Fatalf("non-exact create retry should conflict: %v", err) + } + }) + } + differentCreate := tail + differentCreate.Hash = strings.Repeat("c", 64) + if err := memoryStore.Create(request.SessionID, differentCreate); !errors.Is(err, rinruntime.ErrConflict) { + t.Fatalf("different create event should conflict: %v", err) + } + if err := memoryStore.Append(request.SessionID, tail); err != nil { + t.Fatalf("exact append retry should be idempotent: %v", err) + } + for name, nonExact := range nonExactEventRetries(tail) { + t.Run("append-"+name, func(t *testing.T) { + if err := memoryStore.Append(request.SessionID, nonExact); !errors.Is(err, rinruntime.ErrConflict) { + t.Fatalf("non-exact append retry should conflict: %v", err) + } + }) + } + conflict := tail + conflict.Hash = strings.Repeat("f", 64) + if err := memoryStore.Append(request.SessionID, conflict); !errors.Is(err, rinruntime.ErrConflict) { + t.Fatalf("different event at current sequence should conflict: %v", err) + } + wrongHead := tail + wrongHead.Sequence++ + wrongHead.Hash = strings.Repeat("e", 64) + wrongHead.PrevHash = strings.Repeat("d", 64) + if err := memoryStore.Append(request.SessionID, wrongHead); !errors.Is(err, rinruntime.ErrConflict) { + t.Fatalf("unexpected previous hash should conflict: %v", err) + } + after, err := memoryStore.Load(request.SessionID) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(after, events) { + t.Fatalf("memory-store retries/conflicts mutated the log:\nbefore=%+v\nafter=%+v", events, after) + } +} + func TestSnapshotFileIsPrivate(t *testing.T) { directory := t.TempDir() fileStore, _ := store.OpenFile(directory) @@ -110,3 +297,23 @@ func fileCreateRequest() protocol.CreateSessionRequest { }}, } } + +func nonExactEventRetries(event protocol.EventRecord) map[string]protocol.EventRecord { + data := event + data.Data = append(append([]byte(nil), event.Data...), ' ') + eventType := event + eventType.Type += ".tampered" + requestID := event + requestID.RequestID += ".tampered" + prevHash := event + prevHash.PrevHash += "0" + recordedAt := event + recordedAt.RecordedAt += "0" + return map[string]protocol.EventRecord{ + "data-bytes": data, + "type": eventType, + "request-id": requestID, + "prev-hash": prevHash, + "recorded-at": recordedAt, + } +} diff --git a/store/memory.go b/store/memory.go index 244c8c6..6ecace8 100644 --- a/store/memory.go +++ b/store/memory.go @@ -25,20 +25,31 @@ func NewMemory() *Memory { func (s *Memory) Create(sessionID string, event protocol.EventRecord) error { s.mu.Lock() defer s.mu.Unlock() - if _, exists := s.events[sessionID]; exists { + if events, exists := s.events[sessionID]; exists { + if len(events) == 1 && rinruntime.EventRecordsExactlyEqual(events[0], event) { + return nil + } return rinruntime.ErrConflict } - s.events[sessionID] = []protocol.EventRecord{event} + s.events[sessionID] = []protocol.EventRecord{cloneEventRecord(event)} return nil } func (s *Memory) Append(sessionID string, event protocol.EventRecord) error { s.mu.Lock() defer s.mu.Unlock() - if _, exists := s.events[sessionID]; !exists { + events, exists := s.events[sessionID] + if !exists { return rinruntime.ErrNotFound } - s.events[sessionID] = append(s.events[sessionID], event) + last := events[len(events)-1] + if rinruntime.EventRecordsExactlyEqual(event, last) { + return nil + } + if event.Sequence != last.Sequence+1 || event.PrevHash != last.Hash { + return rinruntime.ErrConflict + } + s.events[sessionID] = append(events, cloneEventRecord(event)) return nil } @@ -49,7 +60,11 @@ func (s *Memory) Load(sessionID string) ([]protocol.EventRecord, error) { if !exists { return nil, rinruntime.ErrNotFound } - return append([]protocol.EventRecord(nil), events...), nil + result := make([]protocol.EventRecord, len(events)) + for index, event := range events { + result[index] = cloneEventRecord(event) + } + return result, nil } func (s *Memory) ListSessions() ([]string, error) { @@ -72,3 +87,8 @@ func (s *Memory) SaveSnapshot(sessionID string, snapshot protocol.Snapshot) erro s.snapshots[sessionID] = snapshot return nil } + +func cloneEventRecord(event protocol.EventRecord) protocol.EventRecord { + event.Data = append([]byte(nil), event.Data...) + return event +}