From 39a9af78063d665df972a4b4ef24a7f9c18a2f94 Mon Sep 17 00:00:00 2001 From: CC#3 Kora Runtime Date: Wed, 20 May 2026 14:11:11 -0700 Subject: [PATCH] =?UTF-8?q?feat(KR-7):=20swap=20chain-event-emit=20defer?= =?UTF-8?q?=20for=20kora=5F=5Fappend=5Fevent=20MCP=20call=20=E2=80=94=20cl?= =?UTF-8?q?oses=20D-kr2-st4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical follow-on swap unlocked by K-9 (substrate `f8487059`) + KR-7a (`b6415692`). events.py:emit_kora_event body replaces `raise ChainEventEmitNotAvailableError()` with `await mcp_client.invoke('kora__append_event', {...})` returning the substrate-assigned event_id. ~30 LOC across events.py + provider.py + tools/iso_node.py + tests + docs. Production-test posture per IsoKron PM #27: * K-9's kora__append_event handler is a notImplementedHandler stub on substrate main; substrate-team's dispatch tier (queued) un-stubs + resolves Layer-A wsk_* auth → Layer-B actor_kind='kora'. * KR-7's code shape is sound; mock tests verify correctness. * Production deploys wait on substrate dispatch tier landing — identical posture to KR-7a (token-not-yet-issued; ships green). * Verify-at-first-live-emit step per spec § 3: confirm event_log.actor_id resolves to the 0076-seeded canonical Kora actor (actor_kind='kora' AND workspace_id=); if it resolves to a token-UUIDv5 instead (cowork-claude-pm precedent), small substrate patch needed. events.py: * emit_kora_event body: live call to mcp_client.invoke. K-9 contract output: {'event_id': ''} — projected to a plain str return. * Defensive: None mcp_client raises ValueError ("caller must resolve via IsoKronConnection.get_mcp_client() before calling"); unexpected response shape raises RuntimeError. * mcp_client param is now required (was Optional with deferred-error default). Forward signature stays caller-stable from KR-2 ST4. * ChainEventEmitNotAvailableError class kept exported tagged [kora.isokron.deprecated] for one release so any downstream pinned tests resolve. Will be removed when KR-N audits show no remaining references. provider.py:_attempt_chain_event_emit: * Stop catching ChainEventEmitNotAvailableError (no longer raised). * Fetch IsoKronMCPClient via self._connection.get_mcp_client() (wired in KR-7a); failure to obtain the client is caught + logged at ERROR ([kora.chain.emit.failed] ... MCP client unavailable: ...) so lifecycle hooks (on_session_end / on_delegation / on_memory_write / sync_turn) stay alive on substrate downtime. * Catch IsoKronMCPInvocationError + any defensive Exception at the lifecycle boundary — log at ERROR (not WARNING) since chain-event- emit failure is operator-visibility-worthy per PM-lean ("substrate- side issue worth surfacing"). Sessions don't crash; emits drop with a loud log line. * Successful emits log INFO [kora.chain.emit] with the event_id and invalidate the per-workspace events cache so the next system_prompt_block §6 re-reads. tools/iso_node.py:_handle_iso_node_supersede: * The kora.node.superseded emit call site previously imported emit_kora_event directly and passed mcp_client=None. Refactored to route through provider._attempt_chain_event_emit so all chain-emit paths share the same error-handling + cache-invalidation logic. Tests: * test_events.py — replaced test_emit_kora_event_raises_deferred_ write_error with four MCP-call-path tests: - invokes 'kora__append_event' with expected arg shape; returns the substrate event_id. - propagates IsoKronMCPInvocationError on substrate-side error. - rejects None mcp_client with ValueError (defensive). - rejects unexpected response shape with RuntimeError (defensive). Plus one deprecation-runway test asserting the ChainEventEmitNotAvailableError class is still importable post-KR-7. * test_provider_end_to_end.py — _FakeProviderConnection now exposes get_mcp_client() returning a _FakeMcpClient that records invokes. E2E asserts both emits (on_delegation + on_session_end) hit kora__append_event with the spec-pinned tool name + arg shape; logs [kora.chain.emit] INFO each time. Scratchpad-write deferrals stay at 3 (D-kr2-st3 still open). * test_isokron_provider_skeleton.py — unchanged (didn't reference D-kr2-st4 specifics). BUILD_DEVIATIONS: * D-kr2-st4-no-chain-emit-mcp-tool moved from Open to Closed with Rule-5 spec-quote + production-test posture + deprecation-runway note. 3 deferrals remain open (D-kr2-st2 / D-kr2-st3 / D-kr3-st2). README "Operator pitfalls": * Deferred-surface table trimmed 4 → 3 rows. Recently-closed list gains the KR-7 closure under D-kr2-st4. * Individual "Chain event emission deferred" pitfall replaced with the KR-7-shape description: routes via kora__append_event; successful emits log [kora.chain.emit] INFO; substrate failures log [kora.chain.emit.failed] ERROR; production posture flagged. * D-kr2-st2 row updated — K-7 already merged 2026-05-20; PM drafts the swap bucket. Local gates: * ty check — 7,337 diagnostics, zero-delta vs KR-7a baseline. * pytest tests/plugins/memory/ — 351/351 passing. * Full suite via xdist (-n auto): 24,561 / 203 failed / 52 errors / 129 skipped. Higher xdist isolation variance this run (kanban_tools test_board_param errors aren't reproducible in isolation; none touch isokron). 3 deferrals still open pending CC#1 substrate buckets: * D-kr2-st2-capability-matrix-mirror ← KR-N swap (K-7 merged; PM drafts) * D-kr2-st3-no-scratchpad-write-mcp-tool ← K-8 * D-kr3-st2-no-relationlink-write-mcp-tool ← K-10 Co-Authored-By: Claude Opus 4.7 (1M context) --- BUILD_DEVIATIONS.md | 80 +++++++----- plugins/memory/isokron/README.md | 43 ++++-- plugins/memory/isokron/events.py | 102 ++++++++++----- plugins/memory/isokron/provider.py | 76 ++++++++--- plugins/memory/isokron/tools/iso_node.py | 36 +++--- tests/plugins/memory/test_events.py | 122 ++++++++++++++++-- .../memory/test_provider_end_to_end.py | 56 +++++++- 7 files changed, 380 insertions(+), 135 deletions(-) diff --git a/BUILD_DEVIATIONS.md b/BUILD_DEVIATIONS.md index 02ff0258fe28..5ca724ecd2b6 100644 --- a/BUILD_DEVIATIONS.md +++ b/BUILD_DEVIATIONS.md @@ -57,41 +57,6 @@ Format: - `tests/plugins/memory/test_iso_link_tools.py:test_create_relationlink_raises_deferred_write_error` asserts the message contains all three blockers. -### D-kr2-st4-no-chain-emit-mcp-tool - -- **Bucket**: KR-2 ST4 (chain event emission + recent events read + finalize) -- **Why**: Spec § ST4 § 1 mandates chain events go through a Sea MCP - tool (working name `kora__append_event`) — direct INSERT into - `hivex_foundation.event_log` is forbidden because it would skip the - substrate's `_emit_chain_event` SECDEF (which sets `prev_event_hash` / - `this_event_hash` to maintain chain witness integrity). The Sea MCP - server on substrate main `28ff4f78` exposes only - `kora__propose_convention`, `kora__read_escalation_queue`, - `kora__propose_policy_change` — no append-event tool. Same pattern - as the ST3 scratchpad-write deferral. -- **Closes when**: A Sea MCP append-event tool ships (working name - `kora__append_event`; PM coordinates with substrate-team / files - the substrate dispatch — likely K-9 on CC#1's lane, queued behind - K-7 + K-8). When it lands, `events.emit_kora_event` body switches - from `raise ChainEventEmitNotAvailableError()` to - `mcp_client.invoke('kora__append_event', ...)`. Caller signature - stays unchanged — `provider._attempt_chain_event_emit` and every - lifecycle hook that uses it (`sync_turn`, `on_memory_write`, - `on_session_end`, `on_delegation`) keep working without refactor. -- **Guarded by**: - - `plugins/memory/isokron/events.py` — top-of-module `[kora.isokron.todo]` - tag; `ChainEventEmitNotAvailableError` carries the deviation ID in - every raised message. - - `IsoKronMemoryProvider._attempt_chain_event_emit` — catches - `ChainEventEmitNotAvailableError` + logs a one-line WARNING - tagged with the deviation ID and the event_type that was skipped. - Operators grep `D-kr2-st4-no-chain-emit-mcp-tool` in logs. - - `plugins/memory/isokron/README.md` § "Operator pitfalls" — - chain event deferral notice. - - `tests/plugins/memory/test_events.py` — - `test_emit_kora_event_raises_deferred_write_error` asserts the - error message + tag stay correct. - ### D-kr2-st3-no-scratchpad-write-mcp-tool - **Bucket**: KR-2 ST3 (Scratchpad reads + writes) @@ -154,6 +119,51 @@ Format: ## Closed +### D-kr2-st4-no-chain-emit-mcp-tool — closed by KR-7 (2026-05-20) + +- **Bucket**: KR-2 ST4 (chain event emission) +- **Resolved by**: KR-7 — `plugins/memory/isokron/events.py:emit_kora_event` + body swaps from `raise ChainEventEmitNotAvailableError()` to + `await mcp_client.invoke('kora__append_event', {...})`. Returns the + K-9 substrate tool's `event_id` (UUID string). The provider's + `_attempt_chain_event_emit` fetches the real + :class:`IsoKronMCPClient` via `IsoKronConnection.get_mcp_client()` + (wired in KR-7a) and surfaces substrate-side failures as + `IsoKronMCPInvocationError` logged at ERROR (lifecycle hooks catch + + log so the session stays alive). `iso_node_supersede`'s + `kora.node.superseded` emit routes through the same helper instead + of duplicating the wiring. +- **Spec quote** (KR-7 § 0): *"CC#1 just shipped K-9 (`f8487059`): + the `kora__append_event` Sea MCP tool now exists. KR-7 swaps CC#3's + KR-2 ST4 deferred-emit path from the placeholder error to a real + MCP call. ~20-40 lines Python; single PR; closes one + BUILD_DEVIATIONS."* +- **Production-test posture** (IsoKron PM #27): K-9's + `kora__append_event` handler is currently a `notImplementedHandler` + stub on substrate main; substrate-team's dispatch tier (queued) + bridges Layer-A `wsk_*` auth → Layer-B `actor_kind='kora'` and + un-stubs the handler. KR-7's code shape is sound and ships green + with mock tests; production deploys wait on the dispatch tier + landing. Verify-at-first-live-emit step: confirm + `event_log.actor_id` resolves to the 0076-seeded canonical Kora + actor (`actor_kind='kora' AND workspace_id=`) — if it resolves to a token-UUIDv5 instead (the + `cowork-claude-pm` precedent), small substrate patch needed. +- **Deprecation runway**: `ChainEventEmitNotAvailableError` class + kept exported (tagged `[kora.isokron.deprecated]`) for one release + so any pinned downstream tests still import it. Class removal + scheduled when KR-N audits show no remaining references. +- **Guarded by**: + - `tests/plugins/memory/test_events.py` — replaced the + deferred-error test with four MCP-call-path tests covering happy, + error propagation, None-client defense, and unexpected-response + shape; deprecation-runway test asserts the class is still + importable. + - `tests/plugins/memory/test_provider_end_to_end.py` — + `_FakeProviderConnection` now exposes `get_mcp_client()` returning + a `_FakeMcpClient`; E2E asserts both emits succeed with the + spec-pinned tool name + arg shape. + ### D-kr3-st1-capability-check-deferred — closed by KR-6 (2026-05-20) - **Bucket**: KR-3 ST1 (`iso_node_*` tool family) diff --git a/plugins/memory/isokron/README.md b/plugins/memory/isokron/README.md index d6caec765e35..6fc745cad8d8 100644 --- a/plugins/memory/isokron/README.md +++ b/plugins/memory/isokron/README.md @@ -93,26 +93,34 @@ This `README.md` ships with **KR-2 ST1**, which delivers the structural skeleton ## Operator pitfalls -### Deferred-surface summary (4 open BUILD_DEVIATIONS as of KR-6) +### Deferred-surface summary (3 open BUILD_DEVIATIONS as of KR-7) -All four follow the same shape: signature is forward-stable, body +All three follow the same shape: signature is forward-stable, body swaps from `raise ` to `mcp_client.invoke(...)` when the substrate-side dependency lands. **No caller refactor needed**. Operators grep the deviation_id in logs to track defer rates. | Deviation | What's deferred | Closes when | |---|---|---| -| `D-kr2-st2-capability-matrix-mirror` | C2 Python mirror of `ACTOR_CAPABILITY_MATRIX` Kora column (parity test guards drift) | K-7 ships Sea MCP `kora__read_kora_capability_row` | +| `D-kr2-st2-capability-matrix-mirror` | C2 Python mirror of `ACTOR_CAPABILITY_MATRIX` Kora column (parity test guards drift) | K-7→KR-N swap (Sea MCP `kora__read_kora_capability_row` shipped 2026-05-20; PM drafts swap bucket) | | `D-kr2-st3-no-scratchpad-write-mcp-tool` | Scratchpad writes from `sync_turn` / `on_memory_write` / `iso_node_create` / `iso_node_supersede` | K-8 ships Sea MCP `kora__write_agent_scratchpad` | -| `D-kr2-st4-no-chain-emit-mcp-tool` | Chain event emission from `on_session_end` / `on_delegation` / `iso_node_supersede` | K-9 ships Sea MCP `kora__append_event` | | `D-kr3-st2-no-relationlink-write-mcp-tool` | `iso_link_create` writes — 3 substrate blockers in one (actor_kind CHECK + missing MCP tool + chain_event_id SECDEF) | K-10 ships the bundled substrate bucket | -**Recently closed**: `D-kr3-st1-capability-check-deferred` — KR-6 -shipped the Python `actor_has_capability` helper at -`plugins/memory/isokron/capability_check.py`. Every `iso_*` tool now -gates through a real check; denied calls surface a structured -`{"ok": false, "denied": true, "capability": ..., "reason": ...}` -envelope. +**Recently closed**: +- `D-kr2-st4-no-chain-emit-mcp-tool` — KR-7 swapped `emit_kora_event` + to route through `kora__append_event` via the KR-7a-wired + `IsoKronMCPClient`. Substrate-side failures surface as + `IsoKronMCPInvocationError` logged at ERROR; lifecycle hooks catch + + log so the session stays alive. Production-test posture per + IsoKron PM #27: substrate-team dispatch tier (queued) un-stubs the + K-9 handler; live emits will fail until then but the code shape is + correct. +- `D-kr3-st1-capability-check-deferred` — KR-6 shipped the Python + `actor_has_capability` helper at + `plugins/memory/isokron/capability_check.py`. Every `iso_*` tool + now gates through a real check; denied calls surface a structured + `{"ok": false, "denied": true, "capability": ..., "reason": ...}` + envelope. ### MCP client (KR-7a) @@ -140,7 +148,20 @@ idempotency) is fully tested with mocked transports. ### Individual pitfalls -* **Chain event emission is currently deferred — `kora.*` events are NOT being written to `event_log`.** KR-2 ST4 ships the emit API (`events.emit_kora_event`) but the substrate-side Sea MCP tool (`kora__append_event` or equivalent) doesn't exist yet (substrate main `28ff4f78`). Until it lands, every emit raises `ChainEventEmitNotAvailableError`; `sync_turn` / `on_memory_write` / `on_delegation` / `on_session_end` catch it + log a one-line WARNING tagged `D-kr2-st4-no-chain-emit-mcp-tool`. This is a chain-audit gap — operators inspecting Kora's recent activity via the `system_prompt_block` §6 section will see only events that landed in `event_log` through other paths (e.g. SECDEF-emitted events from `compact_scratchpad`). Direct INSERT into `event_log` is forbidden — it would skip the `_emit_chain_event` SECDEF and break the `prev_event_hash` / `this_event_hash` witness chain. Tracked in `BUILD_DEVIATIONS.md`. +* **Chain event emission routes through `kora__append_event` via the + KR-7a-wired `IsoKronMCPClient`.** KR-7 closed the deferred-emit surface. + `events.emit_kora_event` now calls `mcp_client.invoke('kora__append_event', …)` + and returns the substrate-assigned `event_id`. Substrate-side + failures surface as `IsoKronMCPInvocationError`; the provider's + `_attempt_chain_event_emit` catches at the lifecycle boundary + (`on_session_end` / `on_delegation` / `iso_node_supersede`) and logs + at ERROR (`[kora.chain.emit.failed]`) so operators see drops without + the session crashing. Successful emits log INFO `[kora.chain.emit]` + with the event_id. **Production-test posture** per IsoKron PM #27: + the substrate-side K-9 handler is currently a `notImplementedHandler` + stub; the dispatch tier (queued substrate-team) un-stubs + resolves + Layer-A→Layer-B `actor_kind='kora'`. Until then live emits return + substrate errors; mock-tested code shape stays correct. * **`event_log` is the one genuine `tenant_id UUID`-keyed substrate table.** Every other Kora table (`kora_role_charter`, `kora_policy_registry`, `kronicle.agent_scratchpad_entries`, `kronicle.workspace_constitution_revisions`) is `workspace_id TEXT`-keyed. `read_recent_kora_events` resolves the workspace_id (Clerk `org_*`) to tenant_id via `JOIN hivex_foundation.tenant ON t.clerk_org_id = $1`. If you bypass `events.read_recent_kora_events` and write your own SQL, replicate the JOIN — a `WHERE workspace_id = $1` against `event_log` will fail (no such column on that table). diff --git a/plugins/memory/isokron/events.py b/plugins/memory/isokron/events.py index 3a604b625b86..33d3b12247ce 100644 --- a/plugins/memory/isokron/events.py +++ b/plugins/memory/isokron/events.py @@ -1,4 +1,4 @@ -"""Chain event emit + recent events read (KR-2 ST4). +"""Chain event emit + recent events read (KR-2 ST4 + KR-7). Two halves: @@ -11,15 +11,16 @@ t.clerk_org_id = $1``. Matches the TS reader at ``packages/sea-mcp-server/src/kora/context-assembler/index.ts:287``. -- :func:`emit_kora_event` — deferred write surface. Chain events go - through the substrate's ``_emit_chain_event`` SECDEF (which sets - ``prev_event_hash`` / ``this_event_hash`` to maintain chain witness - integrity); calling it from runtime Python without the SECDEF wrapper - would break the witness chain. The path is a Sea MCP tool - (working name ``kora__append_event``); as of substrate main - ``28ff4f78``, no such tool is registered. Raises - :class:`ChainEventEmitNotAvailableError` until the tool ships. - BUILD_DEVIATIONS ``D-kr2-st4-no-chain-emit-mcp-tool``. +- :func:`emit_kora_event` — chain event emit via the + ``kora__append_event`` Sea MCP tool. K-9 shipped the substrate tool + (`f8487059`); KR-7 (this swap) replaced the previous + ``ChainEventEmitNotAvailableError`` defer with a real + ``mcp_client.invoke`` call. Returns the new event_id (UUID string). + Production-test posture: substrate-team's dispatch tier (queued) + bridges Layer-A wsk_* auth → Layer-B ``actor_kind='kora'`` and + un-stubs the K-9 handler; until that lands, live calls return + substrate-side errors but the code shape is correct. KR-7a's + ``IsoKronMCPClient`` handles the transport. """ from __future__ import annotations @@ -37,21 +38,25 @@ class ChainEventEmitNotAvailableError(RuntimeError): - """Raised by :func:`emit_kora_event` until the Sea MCP tool ships. - - Same pattern as :class:`scratchpad.ScratchpadWriteNotAvailableError` - — runtime callers MUST NOT bypass with direct INSERT or - ``_emit_chain_event`` calls (would break chain witness integrity). + """[DEPRECATED in KR-7] Raised by the pre-K-9 deferred-emit path. + + Kept exported for one release so any downstream code or pinned + tests that still reference the class still resolve. After KR-7 + (which swapped the defer for a real ``mcp_client.invoke`` call) + this class is no longer raised by ``emit_kora_event``; substrate- + side failures now surface as + :class:`IsoKronMCPInvocationError` from ``mcp_client``. + + BUILD_DEVIATIONS ``D-kr2-st4-no-chain-emit-mcp-tool`` is Closed in + KR-7. Remove this class when KR-N audits show no remaining + references. """ DEFAULT_MESSAGE = ( - "[kora.isokron.todo] chain event emit deferred — Sea MCP server " - "does not yet expose kora__append_event (or equivalent). Tracked " - "in BUILD_DEVIATIONS.md as D-kr2-st4-no-chain-emit-mcp-tool. " - "Direct INSERT into hivex_foundation.event_log bypasses the " - "prev_event_hash / this_event_hash chain — do NOT do that; the " - "MCP tool wraps the substrate's _emit_chain_event SECDEF which " - "preserves chain witness integrity." + "[kora.isokron.deprecated] ChainEventEmitNotAvailableError is " + "obsolete after KR-7 — chain event emits now route through " + "kora__append_event via IsoKronMCPClient. Substrate-side " + "failures surface as IsoKronMCPInvocationError." ) def __init__(self, message: Optional[str] = None): @@ -171,22 +176,49 @@ async def emit_kora_event( workspace_id: str, event_type: str, payload: Any, - mcp_client: Any = None, + mcp_client: Any, ) -> str: - """Emit a ``kora.*`` chain event via the Sea MCP tool surface. + """Emit a ``kora.*`` chain event via the ``kora__append_event`` MCP tool. - Raises ``ChainEventEmitNotAvailableError`` until - ``kora__append_event`` (or equivalent) lands in the Sea MCP - server. Caller signature matches the future MCP-backed - implementation; when the tool ships the body switches to an - ``mcp_client.invoke('kora__append_event', ...)`` call without - any caller-side refactor. + Returns the new ``event_id`` (UUID string) on success. + ``mcp_client`` must be a started :class:`IsoKronMCPClient` + (typically obtained via ``IsoKronConnection.get_mcp_client()``). ``event_type`` must start with ``kora.`` and appear in the ``event_log_event_type_check`` constraint set (foundation/0136 + - foundation/0138 ship the canonical vocabulary). Validation is - enforced substrate-side by the MCP tool — runtime callers pass - the literal through. + foundation/0138 ship the canonical vocabulary). The Sea MCP tool + validates with a Zod regex ``^kora\\.[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$`` + — runtime callers pass the literal through; bad values surface as + ``IsoKronMCPInvocationError`` from the MCP boundary. + + Raises: + IsoKronMCPInvocationError — substrate-side error (CHECK violation, + actor_kind resolution failure, chain lock failure, etc.). + IsoKronMCPNotStartedError — ``mcp_client`` is not started. + ValueError — ``mcp_client`` is ``None`` (defensive: should have + been resolved before calling). """ - del workspace_id, event_type, payload, mcp_client - raise ChainEventEmitNotAvailableError() + if mcp_client is None: + raise ValueError( + "emit_kora_event: mcp_client is required (resolve via " + "IsoKronConnection.get_mcp_client() before calling)" + ) + result = await mcp_client.invoke( + "kora__append_event", + { + "workspace_id": workspace_id, + "event_type": event_type, + "payload": payload, + }, + ) + # K-9 contract: tool returns {'event_id': ''}. + event_id = result.get("event_id") if isinstance(result, dict) else None + if not isinstance(event_id, str): + # Defensive: surface a clear error if the substrate response + # shape drifts (the parity is informal — Zod-strict on the + # input side, but the output is just a dict). + raise RuntimeError( + f"kora__append_event returned unexpected shape: {result!r}; " + f"expected {{'event_id': ''}}" + ) + return event_id diff --git a/plugins/memory/isokron/provider.py b/plugins/memory/isokron/provider.py index 217203160792..4b87c633af49 100644 --- a/plugins/memory/isokron/provider.py +++ b/plugins/memory/isokron/provider.py @@ -19,10 +19,12 @@ * **Active Constitution revision** — ``read_active_constitution_revision`` against ``kronicle.workspace_constitution_revisions``; hex-encoded ``rules_hash`` for K-6 Constitution pre-screen middleware (ST4). -* **Chain event emit** — deferred behind ``ChainEventEmitNotAvailableError`` - until Sea MCP ships ``kora__append_event`` (BUILD_DEVIATIONS - ``D-kr2-st4-no-chain-emit-mcp-tool``). Same shape as the scratchpad - write defer: catch the error in lifecycle hooks, log, continue. +* **Chain event emit** — KR-7 swap to ``kora__append_event`` MCP call + via the KR-7a-wired :class:`IsoKronMCPClient`. K-9 shipped the + substrate tool (`f8487059`); BUILD_DEVIATIONS + ``D-kr2-st4-no-chain-emit-mcp-tool`` closed in KR-7. Substrate-side + failures surface as ``IsoKronMCPInvocationError``; lifecycle hooks + catch + log at ERROR so the session stays alive. * **Session context** — ``assemble_session_context`` returns a ``KoraSessionContext`` mirroring the TS-side ``packages/sea-mcp-server/src/kora/context-assembler/types.ts:130`` @@ -47,10 +49,10 @@ from .config import ISOKRON_CONFIG_SCHEMA, IsoKronProviderConfig from .connection import IsoKronConnection from .events import ( - ChainEventEmitNotAvailableError, RecentChainEvent, emit_kora_event, ) +from .mcp_client import IsoKronMCPInvocationError from .models import ( KoraCapabilityRow, PolicyRegistryEntry, @@ -825,7 +827,7 @@ def save_config(self, values: Dict[str, Any], hermes_home: str) -> None: """ del values, hermes_home - # -- Chain event emit (deferred until Sea MCP tool ships) ---------------- + # -- Chain event emit (KR-7: real Sea MCP call) -------------------------- def _attempt_chain_event_emit( self, @@ -835,33 +837,69 @@ def _attempt_chain_event_emit( payload: Dict[str, Any], origin: str, ) -> None: - """Submit an emit via the dedicated IO loop; catch the defer-error. - - Mirrors :meth:`_attempt_scratchpad_write`'s defer-and-log - pattern. When the Sea MCP tool ships, only - :func:`events.emit_kora_event`'s body changes; this helper - stays identical. + """Emit a ``kora.*`` chain event via the Sea MCP ``kora__append_event`` + tool. Mirrors :meth:`_attempt_scratchpad_write`'s + attempt-then-log pattern but for the chain-emit surface. + + Substrate-side failures (``IsoKronMCPInvocationError``) get + logged at ERROR — chain-event-emit failure is operator-visible + per PM-lean ("substrate-side issue worth surfacing"). Session + lifecycle hooks (``on_session_end`` / ``on_delegation`` / + ``sync_turn``) keep running regardless so a single bad emit + doesn't crash the session. Operators grep + ``[kora.chain.emit.failed]`` in logs to find dropped events. + + Successful emits invalidate the per-workspace events cache so + the next ``system_prompt_block`` §6 re-reads. """ if self._connection is None: raise RuntimeError( f"[kora.isokron] _attempt_chain_event_emit ({origin}) before construct" ) try: - self._connection.submit_and_wait( + mcp_client = self._connection.get_mcp_client() + except Exception as exc: + logger.error( + "[kora.chain.emit.failed] %s emit (%s) — MCP client unavailable: %s", + origin, + event_type, + exc, + ) + return + try: + event_id = self._connection.submit_and_wait( emit_kora_event( workspace_id=workspace_id, event_type=event_type, payload=payload, - mcp_client=None, + mcp_client=mcp_client, ), timeout=10.0, ) - # Successful emit invalidates the events cache so the next - # system_prompt_block re-reads. + logger.info( + "[kora.chain.emit] %s emit %s → event_id=%s", + origin, + event_type, + event_id, + ) self._events_cache.invalidate(workspace_id) - except ChainEventEmitNotAvailableError as exc: - logger.warning( - "[kora.isokron] %s chain event emit skipped (%s) — %s", + except IsoKronMCPInvocationError as exc: + # PM-lean: propagate is the function-level default, but the + # provider-level wrapper catches at the lifecycle boundary + # so session hooks stay alive. ERROR (not WARNING) so the + # failure is operator-visible. + logger.error( + "[kora.chain.emit.failed] %s emit (%s) — %s", + origin, + event_type, + exc, + ) + except Exception as exc: + # Defensive: any other unexpected exception from the MCP + # boundary (transport reset, timeout, etc.) — same + # error-log-but-continue treatment. + logger.error( + "[kora.chain.emit.failed] %s emit (%s) — unexpected: %s", origin, event_type, exc, diff --git a/plugins/memory/isokron/tools/iso_node.py b/plugins/memory/isokron/tools/iso_node.py index 55638c2c9027..e16d7127e14e 100644 --- a/plugins/memory/isokron/tools/iso_node.py +++ b/plugins/memory/isokron/tools/iso_node.py @@ -585,27 +585,21 @@ def _handle_iso_node_supersede( "message": str(exc), } - # Successful write would trigger an emit of kora.node.superseded - # (also deferred per D-kr2-st4). Unreachable in v0.1. - try: - from ..events import emit_kora_event - - assert provider._connection is not None - provider._connection.submit_and_wait( - emit_kora_event( - workspace_id=workspace_id, - event_type="kora.node.superseded", - payload={ - "superseded_entry_id": superseded_entry_id, - "reason": supersession_reason, - }, - ), - timeout=10.0, - ) - except Exception as exc: - logger.warning( - "[kora.isokron] iso_node_supersede emit deferred — %s", exc - ) + # KR-7 closed D-kr2-st4: the supersession emit now routes through + # the live kora__append_event MCP tool via the provider's + # _attempt_chain_event_emit helper (which fetches IsoKronMCPClient + # via get_mcp_client + handles error logging at ERROR level). + # Unreachable in v0.1 anyway because the scratchpad write above + # still defers via D-kr2-st3. + provider._attempt_chain_event_emit( + workspace_id=workspace_id, + event_type="kora.node.superseded", + payload={ + "superseded_entry_id": superseded_entry_id, + "reason": supersession_reason, + }, + origin="iso_node_supersede", + ) return {"ok": True, "entry_id": ""} diff --git a/tests/plugins/memory/test_events.py b/tests/plugins/memory/test_events.py index acb99776ed7b..545791b0ddea 100644 --- a/tests/plugins/memory/test_events.py +++ b/tests/plugins/memory/test_events.py @@ -164,23 +164,127 @@ def test_read_recent_kora_events_handles_datetime_occurred_at(): # --------------------------------------------------------------------------- -# Deferred emit +# KR-7 — MCP-backed emit via kora__append_event # --------------------------------------------------------------------------- -def test_emit_kora_event_raises_deferred_write_error(): - """Until the Sea MCP tool ships, emit raises ChainEventEmitNotAvailableError.""" +class _FakeMcpClient: + """Minimal duck-type for IsoKronMCPClient used in emit tests. + + Records every ``invoke`` call so tests can assert on the tool + name + payload. Configured to return a canned ``event_id`` by + default; tests override ``invoke_result`` or set ``invoke_raises`` + to drive error paths. + """ + + def __init__(self, *, invoke_result=None, invoke_raises=None): + self.invoke_calls: list[tuple[str, dict]] = [] + self._invoke_result = invoke_result or {"event_id": "evt-mock-001"} + self._invoke_raises = invoke_raises + + async def invoke(self, tool_name: str, args: dict): + self.invoke_calls.append((tool_name, dict(args))) + if self._invoke_raises is not None: + raise self._invoke_raises + return self._invoke_result + + +def test_emit_kora_event_invokes_kora__append_event_with_expected_args(): + """The swap routes through ``mcp_client.invoke('kora__append_event', …)`` + with the spec-pinned arg shape.""" + + client = _FakeMcpClient(invoke_result={"event_id": "evt-abc-123"}) async def _run(): - await emit_kora_event( + return await emit_kora_event( workspace_id=WORKSPACE_ID, event_type="kora.session.ended", payload={"turn_count": 5}, + mcp_client=client, + ) + + event_id = asyncio.run(_run()) + assert event_id == "evt-abc-123" + assert len(client.invoke_calls) == 1 + tool_name, args = client.invoke_calls[0] + assert tool_name == "kora__append_event" + assert args == { + "workspace_id": WORKSPACE_ID, + "event_type": "kora.session.ended", + "payload": {"turn_count": 5}, + } + + +def test_emit_kora_event_propagates_mcp_invocation_error(): + """Substrate-side errors propagate as IsoKronMCPInvocationError.""" + from plugins.memory.isokron.mcp_client import IsoKronMCPInvocationError + + client = _FakeMcpClient( + invoke_raises=IsoKronMCPInvocationError( + "kora__append_event", "tenant_id not found" + ) + ) + + async def _run(): + await emit_kora_event( + workspace_id=WORKSPACE_ID, + event_type="kora.session.ended", + payload={"turn_count": 1}, + mcp_client=client, + ) + + with pytest.raises(IsoKronMCPInvocationError) as excinfo: + asyncio.run(_run()) + assert excinfo.value.tool_name == "kora__append_event" + assert "tenant_id not found" in excinfo.value.message + + +def test_emit_kora_event_rejects_none_mcp_client(): + """Defensive: caller must resolve mcp_client before invoking.""" + + async def _run(): + await emit_kora_event( + workspace_id=WORKSPACE_ID, + event_type="kora.session.ended", + payload={}, + mcp_client=None, + ) + + with pytest.raises(ValueError) as excinfo: + asyncio.run(_run()) + assert "mcp_client is required" in str(excinfo.value) + + +def test_emit_kora_event_rejects_unexpected_response_shape(): + """If the substrate tool returns a non-dict or missing event_id, + surface the drift with a RuntimeError rather than returning bogus.""" + + client = _FakeMcpClient(invoke_result={"oops_no_event_id": "x"}) + + async def _run(): + await emit_kora_event( + workspace_id=WORKSPACE_ID, + event_type="kora.session.ended", + payload={}, + mcp_client=client, ) - with pytest.raises(ChainEventEmitNotAvailableError) as excinfo: + with pytest.raises(RuntimeError) as excinfo: asyncio.run(_run()) - msg = str(excinfo.value) - assert "[kora.isokron.todo]" in msg - assert "D-kr2-st4-no-chain-emit-mcp-tool" in msg - assert "BUILD_DEVIATIONS" in msg + assert "kora__append_event returned unexpected shape" in str(excinfo.value) + + +# --------------------------------------------------------------------------- +# Deprecation runway — ChainEventEmitNotAvailableError still importable +# --------------------------------------------------------------------------- + + +def test_chain_event_emit_not_available_error_still_importable_post_kr7(): + """KR-7 marks the class @deprecated but keeps it importable for one + release so any pinned downstream tests resolve. Message now flags + the deprecation (no longer raised by emit_kora_event).""" + + err = ChainEventEmitNotAvailableError() + msg = str(err) + assert "[kora.isokron.deprecated]" in msg + assert "obsolete after KR-7" in msg diff --git a/tests/plugins/memory/test_provider_end_to_end.py b/tests/plugins/memory/test_provider_end_to_end.py index 5da7975fde5c..cd56f2cea309 100644 --- a/tests/plugins/memory/test_provider_end_to_end.py +++ b/tests/plugins/memory/test_provider_end_to_end.py @@ -161,18 +161,40 @@ def acquire(self): return _FakeAcquireCtx(self._conn) +class _FakeMcpClient: + """KR-7-era fake MCP client — records calls + returns a mock event_id. + + Chain emit paths post-KR-7 fetch the client via + ``IsoKronConnection.get_mcp_client()`` then call + ``mcp_client.invoke('kora__append_event', …)``. The E2E test wires + this fake so emits succeed (operator-visible log path, not the + fail-and-recover path). + """ + + def __init__(self): + self.invoke_calls: list[tuple[str, dict]] = [] + + async def invoke(self, tool_name: str, args: dict): + self.invoke_calls.append((tool_name, dict(args))) + return {"event_id": f"evt-mock-{len(self.invoke_calls):03d}"} + + class _FakeProviderConnection: """Drop-in for IsoKronConnection in the E2E test.""" def __init__(self): self._conn = _FakeConnection() self._pool = _FakePool(self._conn) + self._mcp_client = _FakeMcpClient() self.submitted: list = [] self.closed = False def get_pg_pool(self): return self._pool + def get_mcp_client(self): + return self._mcp_client + def submit_and_wait(self, coro, *, timeout: float = 10.0): self.submitted.append(coro) # Run the coroutine on a one-off loop so deferred-write @@ -289,11 +311,35 @@ def test_provider_end_to_end_full_lifecycle(caplog): assert WORKSPACE_ID not in provider._events_cache # No NotImplementedError surfaced anywhere through the full lifecycle. - # Deferred-write WARNINGs were logged but caught. - deferred = [r for r in caplog.records if "skipped" in r.getMessage()] - # Expect at least: sync_turn (1) + on_memory_write (1) + on_delegation - # scratchpad (1) + on_delegation emit (1) + on_session_end emit (1) = 5 - assert len(deferred) >= 5 + # Post-KR-7 reality: + # - Scratchpad-write deferrals stay (D-kr2-st3 still open) — 3 WARNINGs + # tagged "scratchpad write skipped" (sync_turn + on_memory_write + + # on_delegation). + # - Chain emits now succeed via the fake MCP client (KR-7 closed + # D-kr2-st4) — 2 INFO logs tagged [kora.chain.emit]. + scratchpad_skipped = [ + r for r in caplog.records if "scratchpad write skipped" in r.getMessage() + ] + assert len(scratchpad_skipped) == 3 + + chain_emitted = [ + r for r in caplog.records if "[kora.chain.emit]" in r.getMessage() + ] + assert len(chain_emitted) == 2 # on_delegation + on_session_end + assert any( + "kora.handoff.to_claude_pm" in r.getMessage() for r in chain_emitted + ) + assert any( + "kora.session.ended" in r.getMessage() for r in chain_emitted + ) + + # The fake MCP client recorded both emit calls with the spec-pinned + # tool name + arg shape. + fake_client = fake_conn._mcp_client + assert len(fake_client.invoke_calls) == 2 + for tool_name, args in fake_client.invoke_calls: + assert tool_name == "kora__append_event" + assert set(args.keys()) == {"workspace_id", "event_type", "payload"} provider.shutdown() assert provider._initialized is False