Skip to main content

guides/sessions-and-stores.md

# Sessions And Stores

A Jidoka session keeps an agent conversation alive across turns. It stores
request history, snapshots, pending reviews, and the latest result. It does not
own provider clients or long-running processes.

## Use This When

- Use a session when the same agent answers more than one user message.
- Use a session when a turn must resume after a process restart.
- Use a session when a human-in-the-loop interrupt must be picked up later.
- Use a single `Jidoka.turn/3` or `Jidoka.chat/3` call when the work is
  one-shot and the caller does not need to remember anything between turns.
- Use a store when sessions must survive a node restart or be shared across
  workers; keep the in-memory store for tests and local exploration.

## Prerequisites

- A working Jidoka agent. The smallest one is enough; see
  [Getting Started](getting-started.md).
- A provider key in scope for live examples.
- For persistence: a started [`Jidoka.Harness.Store.InMemory`](`Jidoka.Harness.Store.InMemory`)
  process, or a module that implements [`Jidoka.Harness.Store`](`Jidoka.Harness.Store`).

```bash
mix deps.get
mix test
```

## Start A Session

The smallest durable session is a store plus a session id.

```elixir
{:ok, pid} = Jidoka.Harness.Store.InMemory.start_link()
store = {Jidoka.Harness.Store.InMemory, pid: pid}

{:ok, session} =
  Jidoka.Session.start(MyApp.SupportAgent, "support-123", store: store)

{:ok, session, text} =
  Jidoka.Session.chat(session.session_id, "Say hi to Ada.", store: store)
```

That call ran through the same runtime as `Jidoka.turn/3`, then persisted the
updated session under id `"support-123"`. A second `Jidoka.Session.chat/3`
against the same id continues the conversation.

## Concepts

A session is data. Apps usually call `Jidoka.Session`; stores persist the
session data between turns.

```diagram
╭──────────────────────╮
│   Jidoka.Session     │
│ start / run / chat   │
│ resume / replay      │
╰──────────┬───────────╯
           │ reads and writes
╭──────────────────────────╮
│ Durable session data     │
│ spec / requests          │
│ snapshots / result       │
│ pending_reviews          │
╰──────────┬───────────────╯
           │ persists through
╭──────────────────────────╮
│ Store                    │
│ put / get / list / claim │
╰──────────────────────────╯
```

- [`Jidoka.Session`](`Jidoka.Session`) is the developer-facing facade. It
  wraps `start/run/chat/resume` and derives sensible defaults.
- [`Jidoka.Harness.Session`](`Jidoka.Harness.Session`) is the durable data
  struct. Its `schema_version/0` is `1`; older or newer payloads fail at
  normalization rather than silently loading a half-valid session.
- [`Jidoka.Harness.Store`](`Jidoka.Harness.Store`) is the persistence
  behaviour: `put_session/2`, `get_session/2`, `list_sessions/1`, and
  optional `claim_session/3` for atomic single-runner semantics.

A session status is one of `:new`, `:running`, `:hibernated`, `:waiting`,
`:finished`, or `:error`. Jidoka computes it from snapshots, pending reviews,
and the latest result.

## How To

### Step 1: Start A Session

`Jidoka.Session.start/2` accepts a DSL module, a `Jidoka.Agent.Spec`, or a
keyword list of spec attributes. Pass `store:` to persist immediately.

```elixir
{:ok, session} =
  Jidoka.Session.start(MyApp.SupportAgent,
    session_id: "support-123",
    store: store,
    metadata: %{tenant: "acme"}
  )

session.session_id
#=> "support-123"
session.status
#=> :new
session.metadata
#=> %{tenant: "acme"}
```

If no session id is supplied, Jidoka generates one through
`Jidoka.Id.generate/2`. Passing `session_id:` is preferred for any flow that
needs a persistent external handle (a chat thread id, a ticket id, a workflow id).

### Step 2: Run Turns

`Jidoka.Session.run/3` is the full-result API. It returns the underlying
`Jidoka.Turn.Result`, a hibernation snapshot, or an error, along with the
updated session struct so callers without a store still have durable state.

```elixir
{:ok, session, %Jidoka.Turn.Result{} = result} =
  Jidoka.Session.run(session.session_id, "Look up order A1001",
    store: store
  )

result.content
result.events
result.value
```

`Jidoka.Session.chat/3` is the text-only API. It is the right default for
product code.

```elixir
{:ok, session, text} =
  Jidoka.Session.chat(session.session_id, "And what is its status?",
    store: store
  )
```

Both functions accept either a session struct or a session id. With a store
the id is enough; without a store, hold onto the returned struct.

### Step 3: Hibernate And Resume

Pass a checkpoint policy when you want the turn to pause at a safe boundary:

```elixir
{:hibernate, session, snapshot} =
  Jidoka.Session.chat(session.session_id, "Refund order A1001",
    store: store,
    checkpoint: :after_prompt
  )

session.status
#=> :hibernated
```

Resume picks up the latest snapshot recorded on the session:

```elixir
{:ok, session, %Jidoka.Turn.Result{}} =
  Jidoka.Session.resume(session.session_id,
    store: store
  )
```

See [Snapshots And Resume](snapshots-and-resume.md) for the full snapshot
lifecycle and serialization format.

### Step 4: List Pending Reviews

Pending review requests are derived from snapshot metadata when an operation
control returns `{:interrupt, reason}`. They can be listed per session or
across an entire store:

```elixir
{:ok, [%Jidoka.Review.Request{} = request]} =
  Jidoka.Session.pending_reviews(session)

{:ok, all_pending} = Jidoka.Session.pending_reviews(store)
```

The store-level helper iterates `list_sessions/1` and flattens
`session.pending_reviews`, so it works the same for any compliant backend.
For the durable approval flow itself, see
[Human In The Loop](human-in-the-loop.md).

### Step 5: Implement A Custom Store

A store is a module implementing `Jidoka.Harness.Store`. The required
callbacks are small.

```elixir
defmodule MyApp.PostgresSessionStore do
  @behaviour Jidoka.Harness.Store

  alias Jidoka.Harness.Session

  @impl true
  def put_session(%Session{} = session, _opts) do
    MyApp.Repo.upsert_session(session)
    {:ok, session}
  end

  @impl true
  def get_session(session_id, _opts) when is_binary(session_id) do
    case MyApp.Repo.fetch_session(session_id) do
      nil -> {:error, {:session_not_found, session_id}}
      session -> {:ok, session}
    end
  end

  @impl true
  def list_sessions(_opts) do
    {:ok, MyApp.Repo.all_sessions()}
  end
end
```

`claim_session/3` is optional. Implement it when the backend has a native
atomic compare-and-set; otherwise the default fallback uses `get_session/2`
followed by `put_session/2` after rejecting any session already in
`:running` state.

Callers reference a store as either `Module` or `{Module, opts}`. The
in-memory store is `{Jidoka.Harness.Store.InMemory, pid: pid}` so the same
shape works for stores that need configuration (database, namespace, region).

### Step 6: Inspect Sessions

Replay is a data-only projection over what a session already knows. It does
not call any capability and is safe to run anywhere.

```elixir
{:ok, replay} = Jidoka.Session.replay(session)
replay.timeline
replay.journal
replay.pending_reviews
```

For human-readable inspection of a session, snapshot, or request, use
`Jidoka.inspect/1`. For trace projection see
[Tracing And Events](tracing-and-events.md).

## Common Patterns

- **Session per external identifier.** Use the same id the surrounding
  product uses (chat thread, ticket, workflow) instead of generating a fresh
  one. This keeps lookups idempotent.
- **Pass the store on every call.** The store reference is just data, and
  passing it makes the call self-contained. Avoid hiding it behind global
  state.
- **Prefer `chat/3` for product code.** Reach for `run/3` when you need the
  full result, the journal, or to observe a hibernation snapshot.
- **Keep capabilities out of session metadata.** Provider clients, pids,
  and credentials belong in the runtime options for each call, not on the
  serializable session.
- **Use `claim_session/3` in multi-worker deployments.** It is the
  difference between two workers racing on the same turn and one worker
  observing `{:error, {:session_already_running, _}}` and backing off.

## Testing

Sessions are easy to test because every capability is injectable. A
deterministic LLM and the in-memory store are usually enough.

```elixir
test "session keeps history across turns" do
  {:ok, pid} = Jidoka.Harness.Store.InMemory.start_link()
  store = {Jidoka.Harness.Store.InMemory, pid: pid}

  llm = fn _intent, journal ->
    case map_size(journal.results) do
      0 -> {:ok, %{type: :final, content: "first"}}
      _ -> {:ok, %{type: :final, content: "second"}}
    end
  end

  {:ok, session} = Jidoka.Session.start(MyApp.SupportAgent, "s1", store: store)
  {:ok, _session, "first"} = Jidoka.Session.chat("s1", "hi", store: store, llm: llm)
  {:ok, session, "second"} = Jidoka.Session.chat("s1", "again", store: store, llm: llm)

  assert length(session.requests) == 2
  assert session.status == :finished
end
```

For multi-worker safety, write a test that calls `Jidoka.Session.run/3`
twice concurrently against the same id and assert one call returns
`{:error, {:session_already_running, _}}`.

## Troubleshooting

| Symptom | Likely Cause | Fix |
| --- | --- | --- |
| `{:error, :missing_harness_store}` | A session id was passed without a `store:` option. | Pass the store on every call, or hold the session struct and pass it directly. |
| `{:error, {:session_not_found, id}}` | The id was never started against this store. | Call `Jidoka.Session.start/3` with `session_id: id, store: store`. |
| `{:error, {:session_already_running, id}}` | Two callers tried to run the same session at the same time. | Serialize callers; if this is expected, retry after the prior call returns. |
| `{:error, {:missing_session_snapshot, id}}` | Resume was called on a session that never hibernated. | Run a new turn instead, or hibernate explicitly with a checkpoint policy. |
| `{:error, {:conflicting_session_ids, _, _}}` | Both `:id` and `:session_id` were passed with different values. | Pass only `:session_id`, or make them equal. |
| `{:error, {:unsupported_session_schema_version, _, 1}}` | A persisted payload predates the current schema. | Migrate the row to schema version 1 or discard it. |

## Reference

Key modules touched in this guide:

- [`Jidoka.Session`](`Jidoka.Session`) - public facade for `start/2`,
  `run/3`, `chat/3`, `resume/2`, `pending_reviews/1`, `replay/1`.
- [`Jidoka.Harness.Session`](`Jidoka.Harness.Session`) - durable session
  struct with `schema_version/0 == 1`.
- [`Jidoka.Harness.Store`](`Jidoka.Harness.Store`) - persistence behaviour.
- [`Jidoka.Harness.Store.InMemory`](`Jidoka.Harness.Store.InMemory`) -
  reference store for tests and examples.
- [`Jidoka.Review.Request`](`Jidoka.Review.Request`) - shape returned by
  `pending_reviews/1`.

## Related Guides

- [Snapshots And Resume](snapshots-and-resume.md) - the durable artifact a
  session hibernates to.
- [Human In The Loop](human-in-the-loop.md) - pending reviews and the
  approve/deny resume path.
- [Tracing And Events](tracing-and-events.md) - what
  `Jidoka.Session.replay/1` projects under the hood.
- [Runtime And Harness](runtime-and-harness.md) - internals for sessions,
  stores, and replay.