oc_chat
Loading...
Searching...
No Matches
session_transition.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/session_transition.py
4
5"""
6The ONE owner of "the current session": prepare-then-commit session transitions.
7
8Purpose
9-------
10Replacing the active session used to mean assigning ``ctx.session`` and hoping
11every other holder of "the current session" noticed. They did not: the agent,
12the REPL loop-local, the event-writer singleton, the F67 trust key, the sandbox
13process-global and the generation-options projection each kept their own copy,
14so ``/session new`` continued the conversation in the OLD session while the UI
15reported the new one — and a URL trusted in the old session stayed trusted in
16the new one.
17
18This module makes ``ctx.session`` the sole authority and gives every other
19holder a two-phase seam to move through:
20
21- **PREPARE** — non-mutating. Touches NO live process state. Every fallible
22 operation (endpoint policy checks, building an SDK client, calling the
23 provider for model caps, ``Path.resolve``, mkdir/open of the event file,
24 ``session_id`` validation) happens here, against a CANDIDATE. A failure
25 leaves the whole OLD tuple intact.
26- **COMMIT** — a TRANSITION CRITICAL SECTION. Uninterruptible, and every step a
27 non-throwing install of a value that PREPARE already built. Because nothing
28 between the first swap and the identity publish can raise OR be interrupted,
29 there is no rollback machinery — it would be unreachable.
30
31Both halves are required for atomicity. Making every step synchronously no-fail
32is not enough on its own: this REPL wires Ctrl+C to ``raise KeyboardInterrupt``
33between turns, and ``dispatch`` is covered by no ``KeyboardInterrupt`` guard, so
34an interrupt landing between two "no-fail" assignments would strand a mixed
35tuple with no recovery. Hence the deferral guard below.
36
37Responsibilities
38----------------
39- :class:`TransitionRejectedError` + :func:`transition_entry_lease` — the ONE
40 entry precondition (main thread, between turns, non-reentrant), enforced
41 BEFORE any prepare work so a rejected call has zero effects.
42- The runtime PHASE (:func:`mark_in_turn` / :func:`mark_between_turns`), owned
43 here and marked by ``main.py`` around ``run_turn``. Initialises to
44 ``BETWEEN_TURNS`` so a transition issued as the first-ever command succeeds.
45- The ``transition_in_progress`` guard + deferred-SIGINT bookkeeping
46 (:func:`transition_in_progress`, :func:`note_pending_sigint`,
47 :func:`consume_pending_sigint`).
48- :class:`PreparedSessionTransition` + :func:`prepare_transition` (a
49 resource-owning context that aborts in REVERSE order on ANY ``BaseException``)
50 + :func:`commit_transition` + the :func:`replace_session` entry point.
51- :class:`IterationStatsSink` — the PROCESS-owned CSV stats sink that sessions
52 borrow, replacing the per-session file descriptor that leaked across
53 transitions.
54- The SHARED endpoint coordinator (:class:`PreparedEndpointActivation` +
55 :func:`commit_endpoint_activation`) so ``/endpoint use`` commits inside the
56 same critical section instead of its own split five-step sequence.
57
58Diagnostics
59-----------
60Domain: OC_CHAT.CORE
61Levels:
62 L1 — a transition aborted (SYSTEM_ERROR / SECURITY_ERROR class).
63 L2 — transition lifecycle: lease taken, prepare done, commit done.
64 L3 — per-owner prepare/install detail; deferred-SIGINT bookkeeping.
65 L4 — module-load coverage marker.
66
67Contracts
68---------
69- PREPARE calls NO mutating live API — not ``rebind_to``,
70 ``set_session_sandbox_root_override``, ``ModelManager.invalidate``,
71 ``EndpointRegistry.switch``, ``attach_default_writer`` / ``detach_default_writer``,
72 nor any COMMIT install primitive.
73- COMMIT installs process-globals and F67 trust BEFORE publishing identity, so
74 the externally visible set never shows a new identity carrying old trust.
75- ``ctx.session = candidate`` is the LAST state mutation of the transaction.
76- On ANY ``BaseException`` before commit, the full old tuple stands and this
77 module leaks no descriptor, no filesystem artifact and no lease.
78- This module never writes another module's private fields; every install goes
79 through an owner-defined method.
80
81Dependencies
82------------
83- oc_diagnostics (structured debug logging via _dbg)
84- oc_chat.core.external_content_gate (lazy; F67 prepared binding + no-throw install)
85- oc_chat.core.event_bootstrap (lazy; prepared event-writer install)
86- oc_chat.tools._sandbox (lazy; no-fail resolved-override install)
87- oc_chat.core.commands (lazy; refresh_presenter_bindings after the commit)
88- oc_chat.core.event_writer (lazy; EventWriter for the prepared writer and its type hint)
89- oc_chat.core.generation_options (lazy; GenerationOptions from the persisted options dict)
90- oc_chat.core.models (lazy; PreparedModelCache type hint)
91- oc_chat.core.persistence (lazy; load, save and supersede the session records)
92- oc_chat.core.provider_plugin (lazy; ProviderEndpoint type hint)
93- oc_chat.core.session (lazy; ChatSession construction and metadata hydration of the candidate)
94- oc_chat.core.startup (lazy; model migration and variable-context refresh of the candidate)
95"""
96from __future__ import annotations
97
98import threading
99from contextlib import contextmanager
100from dataclasses import dataclass, field
101from pathlib import Path
102from typing import TYPE_CHECKING, Any, Iterator, Optional
103
104from oc_diagnostics import _dbg
105
106if TYPE_CHECKING: # pragma: no cover - typing only
107 from oc_chat.core.event_writer import PreparedEventWriter
109 PreparedF67Binding, PreparedRecordBinding,
110 )
111 from oc_chat.core.models import PreparedModelCache
112 from oc_chat.core.provider_plugin import ProviderEndpoint
113 from oc_chat.core.session import ChatSession
114
115_dbg("OC_CHAT.CORE", "session_transition", "module", 4)
116
117
118# ── Runtime phase ───────────────────────────────────────────────────────────
119
120#: The REPL is between turns (a transition may run) vs inside ``run_turn``.
121BETWEEN_TURNS = "between_turns"
122IN_TURN = "in_turn"
123
124#: Initialised to BETWEEN_TURNS at module init, NOT inferred from cancellation
125#: state, so a transition issued as the FIRST-ever command succeeds.
126_phase: str = BETWEEN_TURNS
127
128_lease_held = False
129_transition_in_progress = False
130_pending_sigint = False
131
132
133class TransitionRejectedError(RuntimeError):
134 """The ONE typed rejection for a transition attempted out of contract.
135
136 Raised BEFORE any prepare work, so a rejected call has NO filesystem, bus,
137 registry or process-global effect — in particular no event directory or file
138 was created. There is no serialization alternative: an out-of-contract call
139 is rejected, never queued.
140 """
141
142
143def current_phase() -> str:
144 """Return the runtime phase. Read live; never cache this."""
145 return _phase
146
147
148def mark_in_turn() -> None:
149 """Mark entry into ``run_turn`` (``main.py``, immediately before the call)."""
150 global _phase
151 _phase = IN_TURN
152
153
154def mark_between_turns() -> None:
155 """Mark the ``run_turn`` boundary exit (``main.py``, in a ``finally``)."""
156 global _phase
157 _phase = BETWEEN_TURNS
158
159
161 """Live getter for the COMMIT guard.
162
163 ``main.py``'s SIGINT handler MUST read this through the function on every
164 signal — never import the flag by value, which would snapshot ``False`` at
165 import time and defeat the deferral entirely.
166 """
167 return _transition_in_progress
168
169
171 """Record a Ctrl+C deferred by the critical section. Repeats COALESCE."""
172 global _pending_sigint
173 _pending_sigint = True
174
175
177 """Consume-once, NO-THROW: was a Ctrl+C deferred while we were committing?
178
179 Returns ``True`` at most once per deferred interrupt. The caller maps that
180 to a re-prompt (at the prompt boundary) or to
181 ``token.request_interrupt()`` (when a turn is already queued) — NEVER to a
182 re-raise. Re-raising at the prompt boundary would land in a region where
183 only ``EOFError`` is caught and would EXIT OC Chat; and an injected bare
184 ``KeyboardInterrupt`` behaves differently in the two prompt backends (one
185 re-raises, the other converts it to ``None``), so the consumed flag is the
186 only backend-safe surface.
187 """
188 global _pending_sigint
189 was_pending = _pending_sigint
190 _pending_sigint = False
191 return was_pending
192
193
194@contextmanager
195def uninterruptible_commit() -> Iterator[None]:
196 """Run a no-fail install block with Ctrl+C DEFERRED (plan 217 T3).
197
198 The same critical section :func:`commit_endpoint_activation` opens
199 inline, exposed so a coordinator living in another module can share it
200 rather than reach for the private flag. ``main.py``'s SIGINT handler
201 reads :func:`transition_in_progress` live, so a press landing inside
202 the block is recorded and replayed at the next prompt boundary instead
203 of splitting the tuple being installed.
204
205 Nesting is safe: the flag is restored to whatever it was on entry, so
206 an inner block cannot clear an outer one's deferral.
207 """
208 func = "uninterruptible_commit"
209 global _transition_in_progress
210 previous = _transition_in_progress
211 _transition_in_progress = True
212 try:
213 yield
214 finally:
215 _transition_in_progress = previous
216 _dbg("OC_CHAT.CORE", func, "commit block closed", 3)
217
218
219def _reset_for_tests() -> None:
220 """Test-only: restore module state to a freshly-imported baseline."""
221 global _phase, _lease_held, _transition_in_progress, _pending_sigint
222 _phase = BETWEEN_TURNS
223 _lease_held = False
224 _transition_in_progress = False
225 _pending_sigint = False
226
227
228# ── Entry lease ─────────────────────────────────────────────────────────────
229
230@contextmanager
231def transition_entry_lease() -> Iterator[None]:
232 """Acquire the ONE entry precondition, or reject. Released on ANY exit.
233
234 Three conditions, all checked BEFORE any prepare work:
235
236 - the caller is on the MAIN thread;
237 - the runtime phase is ``BETWEEN_TURNS`` (explicitly marked by ``main.py``
238 around ``run_turn`` — not inferred from cancellation state);
239 - the lease is not already held (non-reentrancy).
240
241 Any violation raises :class:`TransitionRejectedError` with zero effects. The
242 lease is released in a ``finally`` that also covers ``BaseException``, so a
243 ``KeyboardInterrupt`` or ``SystemExit`` mid-transition cannot wedge every
244 later transition.
245
246 This precondition is what forecloses the event-bus swap race: with no
247 publisher able to be mid-dispatch between turns on another thread, the
248 linearizable ``replace_subscription`` needs no in-flight drain.
249 """
250 func = "transition_entry_lease"
251 global _lease_held
252 if threading.current_thread() is not threading.main_thread():
254 "session transitions are main-thread only; "
255 f"called from {threading.current_thread().name!r}"
256 )
257 if _phase != BETWEEN_TURNS:
259 f"session transitions run between turns only; runtime phase is {_phase!r}"
260 )
261 if _lease_held:
262 raise TransitionRejectedError("a session transition is already in progress (reentrant call)")
263 _lease_held = True
264 _dbg("OC_CHAT.CORE", func, "entry lease acquired", 2)
265 try:
266 yield
267 finally:
268 _lease_held = False
269 _dbg("OC_CHAT.CORE", func, "entry lease released", 3)
270
271
272# ── Process-owned iteration stats sink (D4 / ADR-4) ─────────────────────────
273
274#: The CSV header the REPL writes for a new iteration-stats file.
275STATS_CSV_HEADER = (
276 "ts,iter,model,effort,tools,prompt_tokens,completion_tokens,"
277 "cache_hit_tokens,cache_miss_tokens,dur_ms\n"
278)
279
280
282 """A PROCESS-owned iteration-stats CSV file that sessions BORROW.
283
284 Replaces the session-owned file descriptor. Under session ownership a
285 replacement silently dropped ``--stats-file`` / ``--no-iteration-stats``,
286 the finaliser closed only whichever session happened to be current (leaking
287 the others), and a blind append-reopen rewrote the CSV header mid-file.
288
289 Ownership is explicit: this object opens, flushes and closes exactly once;
290 a session holds a BORROWED writer reference it must never close. The actual
291 write path (``react_loop``) is unchanged — it still reads
292 ``session.iteration_stats_file`` / ``session.show_iteration_stats``, which
293 the transition commit points at this sink for every session. That bridge is
294 what makes process ownership real without editing the loop.
295 """
296
297 def __init__(self, path: str | Path, *, show: bool = True) -> None:
298 func = "IterationStatsSink.__init__"
299 if not isinstance(path, (str, Path)):
300 raise TypeError(
301 "IterationStatsSink requires a filesystem path, not a file-like "
302 "object; the sink owns open/close and cannot adopt a pathless handle"
303 )
304 self.path = Path(path)
305 self.show = bool(show)
306 self._fh: Any = None
307 self._closed = False
308 # The header belongs to a NEW or EMPTY file only — appending it to an
309 # existing log would corrupt it with a header line mid-file.
310 needs_header = not self.path.exists() or self.path.stat().st_size == 0
311 self._fh = open(self.path, "a", encoding="utf-8", newline="")
312 if needs_header:
313 self._fh.write(STATS_CSV_HEADER)
314 self._fh.flush()
315 _dbg("OC_CHAT.CORE", func, f"opened {self.path} (header={needs_header})", 2)
316
317 @property
318 def writer(self) -> Any:
319 """The borrowed writer handle a session's ``iteration_stats_file`` points at."""
320 return self._fh
321
322 def flush(self) -> None:
323 """Best-effort flush; never raises into a caller's happy path."""
324 func = "IterationStatsSink.flush"
325 try:
326 if self._fh is not None and not self._closed:
327 self._fh.flush()
328 except Exception as exc:
329 _dbg("OC_CHAT.CORE", func, f"EXTERNAL_ERROR: flush failed: {exc}", 2)
330
331 def close(self) -> None:
332 """Flush + close EXACTLY ONCE. Idempotent and non-raising."""
333 func = "IterationStatsSink.close"
334 if self._closed:
335 return
336 self._closed = True
337 try:
338 if self._fh is not None:
339 self._fh.flush()
340 self._fh.close()
341 except Exception as exc:
342 _dbg("OC_CHAT.CORE", func, f"EXTERNAL_ERROR: close failed: {exc}", 2)
343 finally:
344 self._fh = None
345 _dbg("OC_CHAT.CORE", func, "closed", 2)
346
347 def bind_to(self, session: "ChatSession") -> None:
348 """Point *session* at this sink (the pinned react_loop bridge).
349
350 Pure assignment of a borrowed reference — cannot fail, so it is safe
351 inside the commit critical section. The session never closes it.
352 """
353 session.iteration_stats_file = self._fh
354 session.show_iteration_stats = self.show
355
356
357# ── Prepared values ─────────────────────────────────────────────────────────
358
359@dataclass
361 """Everything a session replacement needs, built while nothing was mutated.
362
363 Each member is the finished product of one owner's fallible work. COMMIT
364 installs them in a pinned order and does nothing else, which is what makes
365 the commit block genuinely no-fail rather than merely called no-fail.
366 """
367
368 candidate: "ChatSession"
369 #: Unattached SDK client from ``ClientInterface.build_candidate``.
370 client: Any = None
371 #: The resolved endpoint the candidate activates.
372 endpoint: Optional["ProviderEndpoint"] = None
373 #: Fully-built next model cache from ``ModelManager.prepare_cache``.
374 model_cache: Optional["PreparedModelCache"] = None
375 #: Opened-but-unsubscribed event writer + its ownership receipt.
376 event_writer: Optional["PreparedEventWriter"] = None
377 #: ALREADY-RESOLVED sandbox override (the one fallible resolve ran in PREPARE).
378 sandbox_override: Optional[Path] = None
379 #: ``GenerationOptions`` converted from the candidate's persisted dict.
380 generation_options: Any = None
381 #: Validated F67 trust rotation, ready for the no-throw install.
382 f67_binding: Optional["PreparedF67Binding"] = None
383 #: Prebuilt ``session_id -> canonical record`` binding for load/switch
384 #: (plan 152-b D4). ``None`` for every non-load replacement path, which is
385 #: what keeps the commit step below byte-compatible for ``replace_session``.
386 record_binding: Optional["PreparedRecordBinding"] = None
387
388
389@dataclass
391 """An ``/endpoint use`` activation prepared for the shared coordinator.
392
393 ``/endpoint use`` used to commit five things in sequence — client, model
394 cache, registry ``active_name``, ``session.endpoint_name``, ``dirty`` —
395 under the same unguarded dispatch as the session seam, so a Ctrl+C between
396 any two of them split the endpoint tuple exactly like the session desync.
397 Preparing them together and committing under the same lease closes that.
398 """
399
400 endpoint: "ProviderEndpoint"
401 client: Any
402 model_cache: "PreparedModelCache"
403 session: "ChatSession"
404
405
406# ── PREPARE ─────────────────────────────────────────────────────────────────
407
408@contextmanager
410 ctx: Any,
411 candidate: "ChatSession",
412) -> Iterator[PreparedSessionTransition]:
413 """Build a :class:`PreparedSessionTransition` without touching live state.
414
415 A RESOURCE-OWNING context. On ANY ``BaseException`` — not just
416 ``Exception`` — the owners abort in REVERSE order and the transition leaves
417 the old tuple untouched. ``BaseException`` matters because the real SIGINT
418 handler raises ``KeyboardInterrupt`` between turns and the COMMIT deferral
419 guard does not exist yet during PREPARE, so an interrupt here would
420 otherwise strand an open descriptor and a half-created event directory.
421
422 Cleanup is idempotent, so a repeated Ctrl+C during the unwind is safe.
423 Ownership transfers to COMMIT only when the block exits normally.
424 """
425 func = "prepare_transition"
426 prepared = PreparedSessionTransition(candidate=candidate)
427 committed = False
428 try:
429 # 1. Endpoint — resolve + policy-check in the OWNER. Non-mutating: no
430 # switch(), no active_name write. Unknown / missing / blocked /
431 # forbidden-host are rejected here.
432 registry = getattr(ctx, "endpoint_registry", None)
433 if registry is not None and getattr(registry, "entries", None):
434 prepared.endpoint = registry.resolve(getattr(candidate, "endpoint_name", "") or "")
435 # 2. Client — built UNATTACHED; the live _client is untouched.
436 prepared.client = ctx.client.build_candidate(prepared.endpoint)
437 # 3. Model cache — the fallible provider.models() / default_caps().
438 prepared.model_cache = ctx.model_manager.prepare_cache(
439 getattr(candidate, "model", "") or ""
440 )
441
442 # 4. Sandbox — the ONE fallible resolve happens HERE, not at commit.
443 prepared.sandbox_override = _resolve_override(
444 getattr(candidate, "sandbox_root_override", None)
445 )
446
447 # 5. Generation options — the dict->GenerationOptions conversion is
448 # pinned to PREPARE because ctx holds the typed value while the
449 # session persists a dict, and react_loop reads the ctx projection.
450 prepared.generation_options = _convert_generation_options(
451 getattr(candidate, "generation_options", None)
452 )
453
454 # 6. F67 — validated trust key, fully built so the install allocates,
455 # validates and logs NOTHING.
456 from oc_chat.core.external_content_gate import prepare_session_binding
457 prepared.f67_binding = prepare_session_binding(candidate)
458
459 # 7. Event writer — the real file I/O (mkdir, resume scan, open) with an
460 # ownership receipt, but NO bus subscribe.
461 from oc_chat.core.event_bootstrap import _resolve_base_dir
462 from oc_chat.core.event_writer import EventWriter
463 prepared.event_writer = EventWriter(_resolve_base_dir(candidate)).open_unsubscribed()
464
465 _dbg("OC_CHAT.CORE", func, "prepare complete (no live state mutated)", 2)
466 yield prepared
467 committed = True
468 except BaseException as exc:
469 _dbg("OC_CHAT.CORE", func,
470 f"SYSTEM_ERROR: prepare aborted: {type(exc).__name__}: {exc}", 1, override=True)
471 raise
472 finally:
473 if not committed:
474 _abort_prepared(prepared)
475
476
477def _abort_prepared(prepared: PreparedSessionTransition) -> None:
478 """Release a prepared transition's resources in REVERSE build order.
479
480 Only the event writer holds anything: the built client and prepared cache
481 are inert values that simply fall away, and no bus/registry/global was ever
482 touched. Idempotent and non-raising.
483 """
484 if prepared.event_writer is not None:
485 prepared.event_writer.abort()
486
487
488def _resolve_override(raw: Any) -> Optional[Path]:
489 """Resolve a sandbox override string ONCE, in PREPARE. May raise here."""
490 if raw is None or str(raw).strip() == "":
491 return None
492 return Path(str(raw)).expanduser().resolve(strict=False)
493
494
495def _convert_generation_options(raw: Any) -> Any:
496 """Convert the persisted generation-options dict into ``GenerationOptions``."""
497 from oc_chat.core.generation_options import GenerationOptions
498 if raw is None:
499 return GenerationOptions()
500 if isinstance(raw, dict):
501 return GenerationOptions(**raw)
502 return raw # already the typed value
503
504
505# ── COMMIT ──────────────────────────────────────────────────────────────────
506
507def commit_transition(ctx: Any, prepared: PreparedSessionTransition) -> None:
508 """Install a prepared transition. UNINTERRUPTIBLE, and every step no-fail.
509
510 The whole block runs under the process-owned ``transition_in_progress``
511 guard, so ``main.py``'s SIGINT handler DEFERS ``KeyboardInterrupt`` instead
512 of raising it between two installs. Order is pinned and load-bearing:
513
514 1. client, 2. sandbox (ctx mirror AND process-global), 3. generation
515 options, 4. model cache, 5. event writer (linearizable bus swap),
516 6. stats-sink bridge, 7. endpoint activation + the CANDIDATE's
517 ``endpoint_name``/``dirty``, 8. F67 trust rotation, 9. ``ctx.session``.
518
519 Identity publishes LAST and trust rotates BEFORE it, so there is never a
520 moment where the new identity carries the old session's trust set. The
521 externally visible set includes the process-globals, not just
522 ``ctx.session``, which is why they precede the publish too.
523
524 Step 10 — the presenter/TurnStats refresh — is POST-transaction and
525 best-effort: it calls arbitrary collaborator methods that a custom presenter
526 could make raise, so it is excluded from the atomic tuple. Stale UI at
527 worst, never a state hybrid.
528 """
529 func = "commit_transition"
530 global _transition_in_progress
531 candidate = prepared.candidate
532 _transition_in_progress = True
533 try:
534 # (1) client
535 if prepared.client is not None:
536 ctx.client.install_prepared(prepared.client)
537 # (2) sandbox — BOTH projections. _sync_sandbox_override reads the ctx
538 # mirror FIRST, so resetting only the global re-poisons from the
539 # stale mirror on the next sync.
540 from oc_chat.tools import _sandbox
541 resolved = prepared.sandbox_override
542 ctx.sandbox_root_override = str(resolved) if resolved is not None else None
543 _sandbox.install_resolved(resolved)
544 # (3) generation options — react_loop reads the CTX projection.
545 ctx.generation_options = prepared.generation_options
546 # (4) model cache
547 if prepared.model_cache is not None:
548 ctx.model_manager.apply_prepared(prepared.model_cache)
549 # (5) event writer — one lock, exactly one wildcard writer throughout.
550 if prepared.event_writer is not None:
551 from oc_chat.core.event_bootstrap import install_prepared_writer
552 install_prepared_writer(prepared.event_writer)
553 # (6) stats sink — the candidate BORROWS the process sink's writer.
554 sink = getattr(ctx, "iteration_stats_sink", None)
555 if sink is not None:
556 sink.bind_to(candidate)
557 # (7) endpoint activation — the committed OBJECT, and the CANDIDATE's
558 # fields (ctx.session is still the OLD session until step 9).
559 if prepared.endpoint is not None:
560 registry = getattr(ctx, "endpoint_registry", None)
561 if registry is not None:
562 registry.commit_endpoint_activation(prepared.endpoint)
563 candidate.endpoint_name = prepared.endpoint.name
564 # (8) F67 trust rotation — BEFORE identity.
565 if prepared.f67_binding is not None:
566 from oc_chat.core.external_content_gate import install_prepared_session_binding
567 install_prepared_session_binding(prepared.f67_binding)
568 # (8b) canonical-record binding (plan 152-b D4) — the ONE sanctioned
569 # amendment to the 152 critical section. A no-throw dict assignment
570 # that installs the load/switch candidate's session_id->locator map
571 # immediately before identity, so _id_to_locator joins the atomic
572 # old/new tuple. ``None`` for every non-load path (replace_session),
573 # so this step is a no-op there and the 152 order is unchanged.
574 if prepared.record_binding is not None:
575 from oc_chat.core.external_content_gate import install_prepared_record_binding
576 install_prepared_record_binding(prepared.record_binding)
577 # (9) identity publish — truly last.
578 ctx.session = candidate
579 finally:
580 _transition_in_progress = False
581 _dbg("OC_CHAT.CORE", func, f"committed session {getattr(candidate, 'name', '?')!r}", 2)
582 _post_transaction(ctx, prepared)
583
584
585def _post_transaction(ctx: Any, prepared: PreparedSessionTransition) -> None:
586 """Best-effort projections AFTER the transaction succeeded. Never fatal.
587
588 ``turn_stats.rebind_session`` runs FIRST: it is an infallible id assignment
589 carrying token accounting, and if a raising presenter went first it would be
590 skipped and usage would keep being attributed to the OLD ``session_id``.
591 Then the deferred F67 lifecycle diagnostic (moved out of the critical
592 section because ``_dbg`` can raise under warnings-as-errors before
593 diagnostics are initialised), then the presenter rebind.
594 """
595 func = "_post_transaction"
596 try:
597 turn_stats = getattr(ctx, "turn_stats", None)
598 if turn_stats is not None:
599 turn_stats.rebind_session(getattr(ctx.session, "session_id", ""))
600 if prepared.f67_binding is not None:
601 from oc_chat.core.external_content_gate import log_session_binding_lifecycle
602 log_session_binding_lifecycle(prepared.f67_binding)
603 from oc_chat.core.commands import refresh_presenter_bindings
604 refresh_presenter_bindings(ctx)
605 except Exception as exc:
606 # The transaction already SUCCEEDED — new state is coherent. A UI
607 # projection failure is reported, never reclassified as a failed
608 # transition.
609 _dbg("OC_CHAT.CORE", func,
610 # OCT-LINT: disable=no_hardcoded_secrets reason="Diagnostic _dbg prose trips the Shannon entropy heuristic (4.50 bits/char at the 4.5 threshold); no secret material"
611 f"EXTERNAL_ERROR: post-transaction refresh failed: {type(exc).__name__}: {exc}",
612 2, override=True)
613
614
615def rename_session(ctx: Any, new_name: str) -> tuple[str, str]:
616 """Rename / save-as — its OWN transition class (plan 152 D8).
617
618 A rename has DIFFERENT invariants from a replacement, which is why it is a
619 separate named transition rather than a reuse of :func:`replace_session`:
620
621 - the canonical ``session_id`` is PRESERVED, so F67 trust must NOT reset —
622 it is the same identity under a new display name. That falls out for free
623 from keying trust on the id;
624 - sandbox, generation options, endpoint and the stats sink are UNCHANGED;
625 - the OLD name is retained until the commit point.
626
627 PREPARE captures the current name's SLUG **before** the name mutation —
628 ``/session save <name>`` overwrites the name and then persists, so any
629 post-mutation capture is already too late and the old event history would
630 become unreachable.
631
632 COMMIT order is deliberate: write the NEW record first, THEN supersede-mark
633 the old one. If the mark fails, two same-id records exist with no mark, and
634 the deterministic newest-meta-timestamp rule resolves to the new record —
635 the same answer. Marking first and then failing to save would instead
636 demote a record that is still canonical.
637
638 Returns ``(log_path, meta_path)``. On a failure before the name mutation the
639 old name and old files stand.
640 """
641 func = "rename_session"
642 from oc_chat.core.event_bootstrap import _slug
643 from oc_chat.core.persistence import (
644 mark_record_superseded, save_chat_log, save_session_metadata,
645 )
646
647 session = ctx.session
648 old_name = getattr(session, "name", "")
649
650 # Not a rename — a plain re-save. No alias, no supersede mark.
651 if not new_name or new_name == old_name:
652 return save_chat_log(session), save_session_metadata(session)
653
654 # PREPARE — capture the outgoing slug BEFORE the name changes.
655 old_slug = _slug(old_name)
656 aliases = list(getattr(session, "legacy_event_slugs", None) or [])
657 new_slug = _slug(new_name)
658 if old_slug and old_slug not in aliases and old_slug != new_slug:
659 aliases.append(old_slug)
660
661 # COMMIT — mutate, persist under the new name, then demote the old record.
662 session.name = new_name
663 session.legacy_event_slugs = aliases
664 log_path = save_chat_log(session)
665 meta_path = save_session_metadata(session)
666 mark_record_superseded(old_name, new_name)
667 _dbg("OC_CHAT.CORE", func,
668 f"renamed {old_name!r} -> {new_name!r}; aliases={len(aliases)}", 2)
669 return log_path, meta_path
670
671
672class ChatLogLoadError(RuntimeError):
673 """A load/switch candidate's chat log could not be read/parsed (plan 152-b D3).
674
675 v2 pins the load/switch chat-log contract to fail CLOSED for a parse/read
676 error: rather than the pre-152-b warn-and-load-empty behaviour (which quietly
677 replaced a corrupt-but-present history with an empty buffer), the whole
678 transition aborts with the old session intact. A MISSING file is still the
679 nonfatal empty-candidate case and does NOT raise.
680 """
681
682
683@dataclass(frozen=True)
685 """The pure RESOLVE result for a load/switch (plan 152-b D2, Sol P1-2).
686
687 Computed values only — normalized metadata, the canonical record locator, and
688 whether the record was non-canonical (id popped). Building it mutates NO
689 ``ctx``, process-global, or live-session state, unlike the legacy
690 ``_metadata_for_activation`` which writes a fresh id onto the LIVE session.
691 """
692
693 metadata: dict
694 locator: str
695 noncanonical_reason: str
696
697
698def _resolve_activation_pure(name: str) -> _ResolvedActivation:
699 """Resolve *name*'s activation metadata WITHOUT touching live state (RESOLVE).
700
701 Replaces the use of ``commands._metadata_for_activation`` for the seam paths:
702 that helper mints a fresh id directly onto ``ctx.session`` for a
703 malformed / superseded / duplicate-id record, which is unusable in a pure
704 RESOLVE. Here the same non-canonical decision is made, but the only effect is
705 popping ``session_id`` from the RETURNED dict — the fresh candidate then keeps
706 its own construction-minted canonical id under ``pure`` hydration.
707 """
708 func = "_resolve_activation_pure"
709 from oc_chat.core.external_content_gate import is_canonical_record
710 from oc_chat.core.persistence import load_session_metadata_located
711
712 loaded = load_session_metadata_located(name)
713 meta = dict(loaded.metadata)
714 locator = loaded.canonical_record_locator
715 sid = meta.get("session_id")
716 persisted_name = meta.get("session_name")
717
718 reason = ""
719 if isinstance(persisted_name, str) and persisted_name and persisted_name != name:
720 reason = "record name disagrees with the store location it was resolved from"
721 elif meta.get("superseded_by"):
722 reason = "record was superseded by a rename"
723 elif not is_canonical_record(sid, locator):
724 reason = "session_id is already owned by a different record in this process"
725
726 if reason:
727 meta.pop("session_id", None)
728 _dbg("OC_CHAT.CORE", func,
729 f"SECURITY_ERROR: non-canonical record for {name!r} ({reason}); the "
730 f"candidate keeps a freshly-minted identity and trust rotates at commit",
731 2, override=True)
732 return _ResolvedActivation(metadata=meta, locator=locator, noncanonical_reason=reason)
733
734
735def _hydrate_candidate_messages(candidate: "ChatSession", name: str) -> None:
736 """Load *name*'s chat log onto the CANDIDATE (PREPARE; fallible).
737
738 Contract (plan 152-b D3, Sol P2-3): a MISSING file is the nonfatal
739 empty-candidate case (today's behaviour, retained); a parse/read FAILURE
740 aborts the transition fail-closed via :class:`ChatLogLoadError` rather than
741 silently replacing an unreadable-but-present history with an empty buffer.
742 """
743 from oc_chat.core.persistence import load_chat_log
744 candidate.load_failed = False
745 try:
746 log_data = load_chat_log(name)
747 except FileNotFoundError:
748 candidate.messages = []
749 return
750 except Exception as exc:
751 raise ChatLogLoadError(
752 f"chat log for {name!r} could not be read/parsed ({type(exc).__name__}: "
753 f"{exc}); the on-disk log is preserved and the load was aborted"
754 ) from exc
755 candidate.messages = log_data.get("messages", [])
756
757
759 ctx: Any, name: str,
760) -> tuple["ChatSession", Optional["PreparedRecordBinding"], Optional[str]]:
761 """Build a fully-hydrated load/switch candidate (PREPARE; no live effects).
762
763 Every fallible hydration step (metadata resolve, chat-log read,
764 ``TokenStats.from_dict``, ``set_model``, retired-model migration, legacy /
765 tool-reference migration, variable-context refresh) runs HERE against the
766 detached candidate, so a failure leaves the live session untouched and the
767 lease is released by the caller's context. Returns the candidate, the
768 prepared canonical-record binding, and any captured (deferred) migration
769 notice.
770 """
771 from oc_chat.core.external_content_gate import prepare_record_binding
772 from oc_chat.core.session import (
773 ChatSession, TokenStats, apply_session_metadata_fields,
774 ensure_system_includes_tool_reference, _migrate_legacy_system_prompt,
775 )
776 from oc_chat.core.startup import migrate_session_model, refresh_variable_context
777
778 resolved = _resolve_activation_pure(name)
779 meta = resolved.metadata
780
781 candidate = ChatSession(name=meta.get("session_name", name) or name, messages=[])
782 _hydrate_candidate_messages(candidate, name)
783
784 candidate.reasoning_effort = meta.get("reasoning_effort", candidate.reasoning_effort)
785 candidate.chat_logs = meta.get("chat_logs", [])
786 candidate.stats = TokenStats.from_dict(meta.get("stats", {}))
787 candidate.ui_prefs = dict(meta.get("ui", {}) or {})
788 # PURE hydration: fields land on the candidate; the two live effects
789 # (trust reset, sandbox global) are deferred to the seam's commit.
790 apply_session_metadata_fields(candidate, meta, pure=True)
791
792 record_binding = prepare_record_binding(candidate.session_id, resolved.locator)
793
794 candidate.set_model(meta.get("model", candidate.model))
795 migration_notice = migrate_session_model(
796 candidate, ctx.model_manager.provider, capture=True
797 )
798 candidate.dirty = False
799 _migrate_legacy_system_prompt(candidate, getattr(ctx, "tool_schemas", None))
800 ensure_system_includes_tool_reference(candidate, getattr(ctx, "tool_schemas", None))
801 refresh_variable_context(candidate)
802 return candidate, record_binding, migration_notice
803
804
805def _save_outgoing_snapshot(outgoing: "ChatSession") -> bool:
806 """Save the outgoing session from an IMMUTABLE snapshot (plan 152-b D2 / Sol P1-1).
807
808 The pre-152-b live paths auto-saved the outgoing session in place, mutating
809 its ``chat_logs`` / ``filepath`` / ``dirty`` before the incoming candidate
810 could commit — so a later failure could not leave the old tuple byte-intact.
811 Here a shallow ``copy.copy`` absorbs those writes (``save_*`` only REBIND
812 those attributes and READ the rest), so the LIVE outgoing object — including
813 its ``dirty`` flag, honestly still ``True`` on abort — is never touched.
814
815 Returns ``True`` when a save ran (the outgoing was dirty). Raises on save
816 failure, which aborts the transition with the old tuple intact.
817
818 Pinned boundary (a named residual, NOT a closure claim): this protects the
819 in-memory live tuple. It does NOT provide cross-file all-or-nothing
820 durability — a failure between the chat-log write and the metadata write can
821 still leave one file newer than the other, exactly as today. See report.md.
822 """
823 if not getattr(outgoing, "dirty", False):
824 return False
825 import copy
826 from oc_chat.core.persistence import save_chat_log, save_session_metadata
827 snapshot = copy.copy(outgoing)
828 save_chat_log(snapshot)
829 save_session_metadata(snapshot)
830 return True
831
832
833@dataclass(frozen=True)
835 """What the load/switch handler needs to finish AFTER an atomic transition.
836
837 The seam owns the atomic state move; the command layer owns presenter
838 messaging. This carries the deferred, best-effort, presenter-facing residue
839 (the captured migration notice + whether the outgoing autosave ran) back to
840 the handler so it can emit the notices post-commit.
841 """
842
843 candidate: "ChatSession"
844 migration_notice: Optional[str]
845 outgoing_saved: bool
846
847
848def load_session_into(ctx: Any, name: str) -> LoadTransitionOutcome:
849 """Load persisted session *name* into *ctx* atomically (plan 152-b D2). ENTRY POINT.
850
851 The load/switch analogue of :func:`replace_session`: it owns the whole
852 lifecycle so handlers never hand-assemble the lease or hydrate in place.
853
854 1. LEASE first — a rejected call has zero effects.
855 2. RESOLVE + build a fully-hydrated candidate (pure; no live mutation).
856 3. PREPARE the candidate's owned resources (endpoint/client/cache/sandbox/
857 gen-opts/F67/**writer keyed to the LOADED id** — the audit-plane re-key).
858 4. Save the OUTGOING session from an immutable snapshot (pre-commit external
859 effect); a failure aborts with the old tuple intact.
860 5. COMMIT the 152 critical section (+ the D4 record-binding step); identity
861 publishes LAST.
862
863 Returns a :class:`LoadTransitionOutcome`; the handler emits the deferred
864 notices. On ANY failure before commit the live session, its trust, the
865 sandbox global, the event writer and ``_id_to_locator`` all stand unchanged.
866 """
867 func = "load_session_into"
869 candidate, record_binding, migration_notice = _build_load_candidate(ctx, name)
870 with prepare_transition(ctx, candidate) as prepared:
871 prepared.record_binding = record_binding
872 # Candidate PREPARE has succeeded here; save the outgoing snapshot as
873 # the last pre-commit step, then commit. A save failure raises out of
874 # this block -> prepare_transition aborts -> old tuple intact.
875 outgoing_saved = _save_outgoing_snapshot(ctx.session)
876 commit_transition(ctx, prepared)
877 _dbg("OC_CHAT.CORE", func,
878 f"loaded session {getattr(candidate, 'name', '?')!r} "
879 f"(outgoing_saved={outgoing_saved})", 2)
881 candidate=candidate,
882 migration_notice=migration_notice,
883 outgoing_saved=outgoing_saved,
884 )
885
886
887def replace_session(ctx: Any, candidate: "ChatSession") -> None:
888 """Replace the active session with *candidate*, atomically. THE entry point.
889
890 Used by every replacement path (``/session new``, ``/session clear``
891 option 2, ``/ask`` option 4) and by the post-attach ``Agent.session``
892 setter. Handlers call THIS — a bare ``ctx.session =`` invokes no seam and
893 reintroduces the desync.
894
895 Shape is pinned: the lease precedes PREPARE, PREPARE owns its resources, and
896 COMMIT transfers ownership.
897 """
899 with prepare_transition(ctx, candidate) as prepared:
900 commit_transition(ctx, prepared)
901
902
903# ── Shared endpoint coordinator (R10-03) ────────────────────────────────────
904
905def commit_endpoint_activation(ctx: Any, prepared: PreparedEndpointActivation) -> None:
906 """Commit an ``/endpoint use`` activation inside the SAME critical section.
907
908 Acquires the same ``transition_in_progress`` guard so a Ctrl+C between any
909 two installs is deferred rather than splitting the tuple. Order: client and
910 cache FIRST, then the committed endpoint OBJECT + ``active_name`` +
911 ``session.endpoint_name`` + ``dirty`` LAST.
912
913 Here — and ONLY here — the fields are written on the LIVE session, because
914 ``/endpoint use`` keeps the session it has. The session seam's step (7)
915 writes the CANDIDATE's.
916 """
917 func = "commit_endpoint_activation"
918 global _transition_in_progress
919 _transition_in_progress = True
920 try:
921 ctx.client.install_prepared(prepared.client)
922 ctx.model_manager.apply_prepared(prepared.model_cache)
923 registry = getattr(ctx, "endpoint_registry", None)
924 if registry is not None:
925 registry.commit_endpoint_activation(prepared.endpoint)
926 prepared.session.endpoint_name = prepared.endpoint.name
927 # `dirty` so a runtime endpoint switch survives the dirty-gated autosave
928 # that `/session new` performs on the outgoing session.
929 prepared.session.dirty = True
930 finally:
931 _transition_in_progress = False
932 _dbg("OC_CHAT.CORE", func, f"endpoint committed: {prepared.endpoint.name}", 2)
933
934
935def prepare_endpoint_activation(ctx: Any, name: str) -> PreparedEndpointActivation:
936 """Resolve + build + prepare an ``/endpoint use`` target. PREPARE, fallible.
937
938 A failure here cannot leave registry, client and cache disagreeing, because
939 none of them has been touched.
940 """
941 registry = ctx.endpoint_registry
942 endpoint = registry.resolve(name)
943 client = ctx.client.build_candidate(endpoint)
944 model_cache = ctx.model_manager.prepare_cache(getattr(ctx.session, "model", "") or "")
946 endpoint=endpoint,
947 client=client,
948 model_cache=model_cache,
949 session=ctx.session,
950 )
951
952
953__all__ = [
954 "BETWEEN_TURNS",
955 "IN_TURN",
956 "ChatLogLoadError",
957 "IterationStatsSink",
958 "LoadTransitionOutcome",
959 "PreparedEndpointActivation",
960 "PreparedSessionTransition",
961 "STATS_CSV_HEADER",
962 "TransitionRejectedError",
963 "commit_endpoint_activation",
964 "commit_transition",
965 "consume_pending_sigint",
966 "current_phase",
967 "load_session_into",
968 "mark_between_turns",
969 "mark_in_turn",
970 "note_pending_sigint",
971 "prepare_endpoint_activation",
972 "prepare_transition",
973 "replace_session",
974 "transition_entry_lease",
975 "transition_in_progress",
976]
None __init__(self, str|Path path, *, bool show=True)
PreparedEndpointActivation prepare_endpoint_activation(Any ctx, str name)
None _abort_prepared(PreparedSessionTransition prepared)
tuple["ChatSession", Optional["PreparedRecordBinding"], Optional[str]] _build_load_candidate(Any ctx, str name)
_ResolvedActivation _resolve_activation_pure(str name)
None commit_endpoint_activation(Any ctx, PreparedEndpointActivation prepared)
None _post_transaction(Any ctx, PreparedSessionTransition prepared)
None _hydrate_candidate_messages("ChatSession" candidate, str name)
Optional[Path] _resolve_override(Any raw)
bool _save_outgoing_snapshot("ChatSession" outgoing)
None commit_transition(Any ctx, PreparedSessionTransition prepared)
LoadTransitionOutcome load_session_into(Any ctx, str name)
None replace_session(Any ctx, "ChatSession" candidate)
tuple[str, str] rename_session(Any ctx, str new_name)
Iterator[PreparedSessionTransition] prepare_transition(Any ctx, "ChatSession" candidate)