6The ONE owner of "the current session": prepare-then-commit session transitions.
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
18This module makes ``ctx.session`` the sole authority and gives every other
19holder a two-phase seam to move through:
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.
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.
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
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.
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.
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.
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)
96from __future__
import annotations
99from contextlib
import contextmanager
100from dataclasses
import dataclass, field
101from pathlib
import Path
102from typing
import TYPE_CHECKING, Any, Iterator, Optional
104from oc_diagnostics
import _dbg
109 PreparedF67Binding, PreparedRecordBinding,
115_dbg(
"OC_CHAT.CORE",
"session_transition",
"module", 4)
121BETWEEN_TURNS =
"between_turns"
126_phase: str = BETWEEN_TURNS
129_transition_in_progress =
False
130_pending_sigint =
False
134 """The ONE typed rejection for a transition attempted out of contract.
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.
144 """Return the runtime phase. Read live; never cache this."""
149 """Mark entry into ``run_turn`` (``main.py``, immediately before the call)."""
155 """Mark the ``run_turn`` boundary exit (``main.py``, in a ``finally``)."""
157 _phase = BETWEEN_TURNS
161 """Live getter for the COMMIT guard.
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.
167 return _transition_in_progress
171 """Record a Ctrl+C deferred by the critical section. Repeats COALESCE."""
172 global _pending_sigint
173 _pending_sigint =
True
177 """Consume-once, NO-THROW: was a Ctrl+C deferred while we were committing?
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.
188 global _pending_sigint
189 was_pending = _pending_sigint
190 _pending_sigint =
False
196 """Run a no-fail install block with Ctrl+C DEFERRED (plan 217 T3).
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.
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.
208 func =
"uninterruptible_commit"
209 global _transition_in_progress
210 previous = _transition_in_progress
211 _transition_in_progress =
True
215 _transition_in_progress = previous
216 _dbg(
"OC_CHAT.CORE", func,
"commit block closed", 3)
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
224 _transition_in_progress =
False
225 _pending_sigint =
False
232 """Acquire the ONE entry precondition, or reject. Released on ANY exit.
234 Three conditions, all checked BEFORE any prepare work:
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).
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
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.
250 func =
"transition_entry_lease"
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}"
257 if _phase != BETWEEN_TURNS:
259 f
"session transitions run between turns only; runtime phase is {_phase!r}"
264 _dbg(
"OC_CHAT.CORE", func,
"entry lease acquired", 2)
269 _dbg(
"OC_CHAT.CORE", func,
"entry lease released", 3)
276 "ts,iter,model,effort,tools,prompt_tokens,completion_tokens,"
277 "cache_hit_tokens,cache_miss_tokens,dur_ms\n"
282 """A PROCESS-owned iteration-stats CSV file that sessions BORROW.
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.
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.
297 def __init__(self, path: str | Path, *, show: bool =
True) ->
None:
298 func =
"IterationStatsSink.__init__"
299 if not isinstance(path, (str, Path)):
301 "IterationStatsSink requires a filesystem path, not a file-like "
302 "object; the sink owns open/close and cannot adopt a pathless handle"
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=
"")
313 self.
_fh.write(STATS_CSV_HEADER)
315 _dbg(
"OC_CHAT.CORE", func, f
"opened {self.path} (header={needs_header})", 2)
319 """The borrowed writer handle a session's ``iteration_stats_file`` points at."""
323 """Best-effort flush; never raises into a caller's happy path."""
324 func =
"IterationStatsSink.flush"
328 except Exception
as exc:
329 _dbg(
"OC_CHAT.CORE", func, f
"EXTERNAL_ERROR: flush failed: {exc}", 2)
332 """Flush + close EXACTLY ONCE. Idempotent and non-raising."""
333 func =
"IterationStatsSink.close"
338 if self.
_fh is not None:
341 except Exception
as exc:
342 _dbg(
"OC_CHAT.CORE", func, f
"EXTERNAL_ERROR: close failed: {exc}", 2)
345 _dbg(
"OC_CHAT.CORE", func,
"closed", 2)
347 def bind_to(self, session:
"ChatSession") ->
None:
348 """Point *session* at this sink (the pinned react_loop bridge).
350 Pure assignment of a borrowed reference — cannot fail, so it is safe
351 inside the commit critical section. The session never closes it.
353 session.iteration_stats_file = self.
_fh
354 session.show_iteration_stats = self.
show
361 """Everything a session replacement needs, built while nothing was mutated.
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.
368 candidate:
"ChatSession"
372 endpoint: Optional[
"ProviderEndpoint"] =
None
374 model_cache: Optional[
"PreparedModelCache"] =
None
376 event_writer: Optional[
"PreparedEventWriter"] =
None
378 sandbox_override: Optional[Path] =
None
380 generation_options: Any =
None
382 f67_binding: Optional[
"PreparedF67Binding"] =
None
386 record_binding: Optional[
"PreparedRecordBinding"] =
None
391 """An ``/endpoint use`` activation prepared for the shared coordinator.
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.
400 endpoint:
"ProviderEndpoint"
402 model_cache:
"PreparedModelCache"
403 session:
"ChatSession"
411 candidate:
"ChatSession",
412) -> Iterator[PreparedSessionTransition]:
413 """Build a :class:`PreparedSessionTransition` without touching live state.
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.
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.
425 func =
"prepare_transition"
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 "")
436 prepared.client = ctx.client.build_candidate(prepared.endpoint)
438 prepared.model_cache = ctx.model_manager.prepare_cache(
439 getattr(candidate,
"model",
"")
or ""
444 getattr(candidate,
"sandbox_root_override",
None)
451 getattr(candidate,
"generation_options",
None)
457 prepared.f67_binding = prepare_session_binding(candidate)
463 prepared.event_writer = EventWriter(_resolve_base_dir(candidate)).open_unsubscribed()
465 _dbg(
"OC_CHAT.CORE", func,
"prepare complete (no live state mutated)", 2)
468 except BaseException
as exc:
469 _dbg(
"OC_CHAT.CORE", func,
470 f
"SYSTEM_ERROR: prepare aborted: {type(exc).__name__}: {exc}", 1, override=
True)
478 """Release a prepared transition's resources in REVERSE build order.
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.
484 if prepared.event_writer
is not None:
485 prepared.event_writer.abort()
489 """Resolve a sandbox override string ONCE, in PREPARE. May raise here."""
490 if raw
is None or str(raw).strip() ==
"":
492 return Path(str(raw)).expanduser().resolve(strict=
False)
496 """Convert the persisted generation-options dict into ``GenerationOptions``."""
499 return GenerationOptions()
500 if isinstance(raw, dict):
501 return GenerationOptions(**raw)
508 """Install a prepared transition. UNINTERRUPTIBLE, and every step no-fail.
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:
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``.
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.
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.
529 func =
"commit_transition"
530 global _transition_in_progress
531 candidate = prepared.candidate
532 _transition_in_progress =
True
535 if prepared.client
is not None:
536 ctx.client.install_prepared(prepared.client)
541 resolved = prepared.sandbox_override
542 ctx.sandbox_root_override = str(resolved)
if resolved
is not None else None
543 _sandbox.install_resolved(resolved)
545 ctx.generation_options = prepared.generation_options
547 if prepared.model_cache
is not None:
548 ctx.model_manager.apply_prepared(prepared.model_cache)
550 if prepared.event_writer
is not None:
552 install_prepared_writer(prepared.event_writer)
554 sink = getattr(ctx,
"iteration_stats_sink",
None)
556 sink.bind_to(candidate)
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
565 if prepared.f67_binding
is not None:
567 install_prepared_session_binding(prepared.f67_binding)
574 if prepared.record_binding
is not None:
576 install_prepared_record_binding(prepared.record_binding)
578 ctx.session = candidate
580 _transition_in_progress =
False
581 _dbg(
"OC_CHAT.CORE", func, f
"committed session {getattr(candidate, 'name', '?')!r}", 2)
586 """Best-effort projections AFTER the transaction succeeded. Never fatal.
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.
595 func =
"_post_transaction"
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:
602 log_session_binding_lifecycle(prepared.f67_binding)
604 refresh_presenter_bindings(ctx)
605 except Exception
as exc:
609 _dbg(
"OC_CHAT.CORE", func,
611 f
"EXTERNAL_ERROR: post-transaction refresh failed: {type(exc).__name__}: {exc}",
616 """Rename / save-as — its OWN transition class (plan 152 D8).
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`:
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.
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
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.
638 Returns ``(log_path, meta_path)``. On a failure before the name mutation the
639 old name and old files stand.
641 func =
"rename_session"
644 mark_record_superseded, save_chat_log, save_session_metadata,
647 session = ctx.session
648 old_name = getattr(session,
"name",
"")
651 if not new_name
or new_name == old_name:
652 return save_chat_log(session), save_session_metadata(session)
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)
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
673 """A load/switch candidate's chat log could not be read/parsed (plan 152-b D3).
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.
683@dataclass(frozen=
True)
685 """The pure RESOLVE result for a load/switch (plan 152-b D2, Sol P1-2).
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.
695 noncanonical_reason: str
699 """Resolve *name*'s activation metadata WITHOUT touching live state (RESOLVE).
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.
708 func =
"_resolve_activation_pure"
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")
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"
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",
736 """Load *name*'s chat log onto the CANDIDATE (PREPARE; fallible).
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.
744 candidate.load_failed =
False
746 log_data = load_chat_log(name)
747 except FileNotFoundError:
748 candidate.messages = []
750 except Exception
as exc:
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"
755 candidate.messages = log_data.get(
"messages", [])
760) -> tuple[
"ChatSession", Optional[
"PreparedRecordBinding"], Optional[str]]:
761 """Build a fully-hydrated load/switch candidate (PREPARE; no live effects).
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
773 ChatSession, TokenStats, apply_session_metadata_fields,
774 ensure_system_includes_tool_reference, _migrate_legacy_system_prompt,
779 meta = resolved.metadata
781 candidate =
ChatSession(name=meta.get(
"session_name", name)
or name, messages=[])
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 {})
790 apply_session_metadata_fields(candidate, meta, pure=
True)
792 record_binding = prepare_record_binding(candidate.session_id, resolved.locator)
794 candidate.set_model(meta.get(
"model", candidate.model))
795 migration_notice = migrate_session_model(
796 candidate, ctx.model_manager.provider, capture=
True
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
806 """Save the outgoing session from an IMMUTABLE snapshot (plan 152-b D2 / Sol P1-1).
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.
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.
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.
823 if not getattr(outgoing,
"dirty",
False):
827 snapshot = copy.copy(outgoing)
828 save_chat_log(snapshot)
829 save_session_metadata(snapshot)
833@dataclass(frozen=True)
835 """What the load/switch handler needs to finish AFTER an atomic transition.
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.
843 candidate:
"ChatSession"
844 migration_notice: Optional[str]
849 """Load persisted session *name* into *ctx* atomically (plan 152-b D2). ENTRY POINT.
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.
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
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.
867 func =
"load_session_into"
871 prepared.record_binding = record_binding
877 _dbg(
"OC_CHAT.CORE", func,
878 f
"loaded session {getattr(candidate, 'name', '?')!r} "
879 f
"(outgoing_saved={outgoing_saved})", 2)
882 migration_notice=migration_notice,
883 outgoing_saved=outgoing_saved,
888 """Replace the active session with *candidate*, atomically. THE entry point.
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.
895 Shape is pinned: the lease precedes PREPARE, PREPARE owns its resources, and
896 COMMIT transfers ownership.
906 """Commit an ``/endpoint use`` activation inside the SAME critical section.
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.
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.
917 func =
"commit_endpoint_activation"
918 global _transition_in_progress
919 _transition_in_progress =
True
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
929 prepared.session.dirty =
True
931 _transition_in_progress =
False
932 _dbg(
"OC_CHAT.CORE", func, f
"endpoint committed: {prepared.endpoint.name}", 2)
936 """Resolve + build + prepare an ``/endpoint use`` target. PREPARE, fallible.
938 A failure here cannot leave registry, client and cache disagreeing, because
939 none of them has been touched.
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 "")
948 model_cache=model_cache,
957 "IterationStatsSink",
958 "LoadTransitionOutcome",
959 "PreparedEndpointActivation",
960 "PreparedSessionTransition",
962 "TransitionRejectedError",
963 "commit_endpoint_activation",
965 "consume_pending_sigint",
968 "mark_between_turns",
970 "note_pending_sigint",
971 "prepare_endpoint_activation",
972 "prepare_transition",
974 "transition_entry_lease",
975 "transition_in_progress",
None __init__(self, str|Path path, *, bool show=True)
None bind_to(self, "ChatSession" session)
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 note_pending_sigint()
bool consume_pending_sigint()
Iterator[None] uninterruptible_commit()
None commit_endpoint_activation(Any ctx, PreparedEndpointActivation prepared)
None _post_transaction(Any ctx, PreparedSessionTransition prepared)
None _hydrate_candidate_messages("ChatSession" candidate, str name)
Iterator[None] transition_entry_lease()
Optional[Path] _resolve_override(Any raw)
bool _save_outgoing_snapshot("ChatSession" outgoing)
None commit_transition(Any ctx, PreparedSessionTransition prepared)
None mark_between_turns()
LoadTransitionOutcome load_session_into(Any ctx, str name)
None replace_session(Any ctx, "ChatSession" candidate)
bool transition_in_progress()
tuple[str, str] rename_session(Any ctx, str new_name)
Any _convert_generation_options(Any raw)
Iterator[PreparedSessionTransition] prepare_transition(Any ctx, "ChatSession" candidate)