Skip to content

Commit be6e70d

Browse files
committed
feat(s012): the session process and the agent loop
One gen_statem per conversation under a DynamicSupervisor and a Registry: idle, thinking, tool_wait, approval_wait (stub), compacting (stub), error. A user message is a row, then a broadcast, then a model call in a Task under the Session's own supervisor, talking back only by message; deltas coalesce to at most 20 broadcasts a second and persist as a draft row every 500 ms or 2 KB; the draft is finalised at done (the one edit docs/05 now names); a tool call runs the ToolRunner stub in a Task, writes a tool row, and the loop continues under code-owned caps; a crash or provider error is an error turn with the partial text kept; cancel persists what arrived as interrupted; rehydrate marks a leftover draft interrupted and never resumes; idle hibernates and stops on generic timeouts. Trinity.CorePolicy.hash/0 and the sentinel (three families, tighten only) arrive here as the spec asks. The 010 schema Trinity.Sessions.Session is renamed SessionRow (fix referencing 010): docs/01 gives that name to the process. Seventeen tests, including the two crash tests and 100 concurrent sessions with unchanged pids; the fake provider gained global state and script sequences after the first runs showed its process-local state was invisible to a session's Task. Coverage 60.82%. docs/01 and docs/05 synced. G1 lines 1 to 14. Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent 019d9cf commit be6e70d

30 files changed

Lines changed: 1706 additions & 109 deletions

‎ROADMAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ standards register names the rows that ask for them.
3535
| 003 | FIPS build leg in CI, from source | 0 Foundation | M | 000 | planned |
3636
| 010 | Core domain + persistence (Ecto/SQLite, schemas, Repo owner) | 1 Core loop | M | 000 | approved |
3737
| 011 | LLM provider layer (req_llm behind `Trinity.LLM` behaviour) | 1 Core loop | M | 010 | approved |
38-
| 012 | Session process + agent loop (gen_statem, DynamicSupervisor, rehydration) | 1 Core loop | L | 010, 011 | in_progress |
38+
| 012 | Session process + agent loop (gen_statem, DynamicSupervisor, rehydration) | 1 Core loop | L | 010, 011 | done |
3939
| 013 | LiveView chat UI with streaming | 1 Core loop | M | 012 | planned |
4040
| 020 | Tool protocol + registry | 2 Tools | M | 012 | planned |
4141
| 021 | Permission gate + approval UI (M2 fingerprint-bound, M7) | 2 Tools | M | 020, 013 | planned |

‎config/test.exs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import Config
77
# The MIX_TEST_PARTITION environment variable can be used
88
# to provide built-in test partitioning in CI environment.
99
# Run `mix help test` for more information.
10+
# Slice 012: sessions hibernate and stop quickly in tests so AC8 is observable in seconds.
11+
config :trinity, :sessions, idle_hibernate_ms: 200, idle_stop_ms: 60_000
12+
1013
# Slice 011: the registry in tests is the scripted fake plus a Mox mock; the live tests set
1114
# their own entries from the environment at runtime.
1215
config :trinity, :llm,

‎coverage.tsv‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ slice_id percent sha date
33
001 30.37 5a9c8f7 2026-09-06
44
010 44.88 45ba4f0 2026-09-20
55
011 51.57 ec5334a 2026-09-20
6+
012 60.82 019d9cf 2026-09-20

‎docs/01-architecture.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ Trinity.Application
1616
├── Trinity.Repo # Ecto (SQLite primary). Slice 010
1717
├── {Phoenix.PubSub, name: Trinity.PubSub} # all fan-out. Slice 010
1818
├── Trinity.Telemetry # metrics + cost ledger. Slice 090
19-
├── {Registry, keys: :unique, name: Trinity.Registry}
20-
├── Trinity.LLM.Supervisor # provider clients, rate limiters. Slice 011
21-
├── Trinity.Sessions.Supervisor (DynamicSupervisor) # one Trinity.Sessions.Session per conversation. Slice 012
22-
│ └── Trinity.Sessions.Session (gen_statem) # states: idle → thinking → tool_wait → approval_wait → compacting
23-
│ └── Trinity.Sessions.TurnTaskSupervisor (Task.Supervisor, per session) # parallel tool calls
19+
├── {Registry, keys: :unique, name: Trinity.Registry} # Slice 012, as built
20+
├── {Task.Supervisor, name: Trinity.LLM.TaskSupervisor} # Slice 011, as built: stream_to/3 runs here.
21+
│ # Trinity.LLM.Supervisor (rate limiters) is not built:
22+
│ # nothing needs a process yet (011 NOTES, follow-up)
23+
├── Trinity.Sessions.Supervisor (DynamicSupervisor) # one Trinity.Sessions.Session per conversation. Slice 012, as built
24+
│ └── Trinity.Sessions.Session (gen_statem) # states: idle → thinking → tool_wait → approval_wait → compacting → error
25+
│ └── Task.Supervisor (started by the Session, linked, unnamed) # the model call and the tool calls of one turn
2426
├── Trinity.Tools.Supervisor # tool runtime (ports, browsers). Slice 020/022
2527
├── Trinity.Permissions.Gate # approval requests + allowlist cache. Slice 021
2628
├── Trinity.Receipts.Supervisor # Slice 024
@@ -126,7 +128,7 @@ Every state transition is persisted before it is broadcast. A crash between pers
126128
- Session state = `%Session.State{}` struct, rebuilt from DB on init; in-memory only for the active turn.
127129
- Never block a Session on I/O: LLM streaming, tool execution, embedding happen in Tasks; Session receives messages.
128130
- PubSub topics: `session:<id>` (turn events), `approvals:<id>`, `gateway:<adapter>`, `system`.
129-
- Backpressure: stream chunks are coalesced to ≤ 20 broadcasts/sec per session (Slice 013).
131+
- Backpressure: stream chunks are coalesced to ≤ 20 broadcasts/sec per session (built at Slice 012: a 50 ms timer in the Session, so 013 receives coalesced deltas).
130132

131133
## Directory layout
132134

‎docs/05-data-model.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ with adapter-specific `execute/1` guarded by `repo().__adapter__()`.
3939
| tool_call_id | string, nullable | |
4040
| usage | map, nullable | prompt/completion tokens, cost |
4141
| provider_meta | map | model, finish reason, latency |
42-
Append-only. Editing is a new message with `parts.supersedes`.
42+
Append-only. Editing is a new message with `parts.supersedes`. One edit is allowed and named (slice 012): an
43+
assistant row written as a draft during a turn (`parts.draft = true`, content updated every 500 ms or 2 KB) becomes
44+
final at the end of the turn (`draft = false`, plus `tool_calls`, `usage`, and `interrupted`, `error` or `cap` when the
45+
turn ended that way); the role, seq and session never change.
4346

4447
### messages_fts (Slice 031): SQLite `fts5(content, session_id UNINDEXED, message_id UNINDEXED)`; on Postgres a
4548
`tsvector` generated column on `messages`.

‎lib/trinity/application.ex‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ defmodule Trinity.Application do
3535
{Phoenix.PubSub, name: Trinity.PubSub},
3636
# Slice 011: streams to a pid run under this supervisor, never as bare tasks.
3737
{Task.Supervisor, name: Trinity.LLM.TaskSupervisor},
38+
# Slice 012: one session process per conversation, found by id.
39+
{Registry, keys: :unique, name: Trinity.Registry},
40+
Trinity.Sessions.Supervisor,
3841
# Start to serve requests, typically the last entry
3942
TrinityWeb.Endpoint
4043
] ++ Trinity.Smoke.children(Trinity.Smoke.argv())

‎lib/trinity/core_policy.ex‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.CorePolicy do
4+
@moduledoc """
5+
A digest over the object code of the modules that decide what the agent may do. Slice 012
6+
introduces it because AC9 (kill and reseed) asserts it; slice 024 extends the list and writes it
7+
into the boot receipt. A reseeded Session is born from this hash and nothing else in process
8+
state.
9+
"""
10+
11+
@modules [
12+
Trinity.Sessions.Session,
13+
Trinity.Sessions.Caps,
14+
Trinity.Sessions.ToolRunner,
15+
Trinity.Sessions.ToolRunner.Stub,
16+
Trinity.Sessions.Sentinel
17+
]
18+
19+
@doc "The modules the hash covers, in order."
20+
@spec modules() :: [module()]
21+
def modules, do: @modules
22+
23+
@doc "SHA-256, hex, over the concatenated object code of `modules/0`."
24+
@spec hash() :: String.t()
25+
def hash do
26+
@modules
27+
|> Enum.map(fn mod ->
28+
{^mod, binary, _path} = :code.get_object_code(mod)
29+
binary
30+
end)
31+
|> IO.iodata_to_binary()
32+
|> then(&:crypto.hash(:sha256, &1))
33+
|> Base.encode16(case: :lower)
34+
end
35+
end

‎lib/trinity/sessions.ex‎

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ defmodule Trinity.Sessions do
88
exports this module alone; `Trinity.Sessions.Store` and the schemas stay inside. Slice 012
99
adds the session process on top of this API and changes nothing here.
1010
"""
11-
use Boundary, deps: [Trinity], exports: []
11+
# Slice 012: Sessions reaches the LLM (docs/01: Sessions depends on LLM, Repo, PubSub).
12+
use Boundary,
13+
deps: [Trinity, Trinity.LLM],
14+
exports: [Events, Message, Persona, SessionRow, Session, Caps]
1215

13-
alias Trinity.Sessions.{Message, Persona, Session, Store}
16+
alias Trinity.Sessions.{Message, Persona, SessionRow, Store}
1417

1518
@type session_id :: String.t()
1619

@@ -23,15 +26,15 @@ defmodule Trinity.Sessions do
2326
def get_persona_by_name(name), do: Store.get_persona_by_name(name)
2427

2528
@doc "Creates a session. `persona_id` is required; `origin` and `status` come from a closed vocabulary."
26-
@spec create_session(map()) :: {:ok, Session.t()} | {:error, Ecto.Changeset.t()}
29+
@spec create_session(map()) :: {:ok, SessionRow.t()} | {:error, Ecto.Changeset.t()}
2730
def create_session(attrs), do: Store.insert_session(attrs)
2831

2932
@doc "The session with this id, or nil."
30-
@spec get_session(session_id()) :: Session.t() | nil
33+
@spec get_session(session_id()) :: SessionRow.t() | nil
3134
def get_session(id), do: Store.get_session(id)
3235

3336
@doc "Sessions, most recently active first. Options: `status:`, `limit:` (default 50)."
34-
@spec list_sessions(keyword()) :: [Session.t()]
37+
@spec list_sessions(keyword()) :: [SessionRow.t()]
3538
def list_sessions(opts \\ []), do: Store.list_sessions(opts)
3639

3740
@doc """
@@ -56,8 +59,8 @@ defmodule Trinity.Sessions do
5659
def history(session_id, opts \\ []), do: Store.history(session_id, opts)
5760

5861
@doc "Marks a session archived."
59-
@spec archive(Session.t()) :: {:ok, Session.t()} | {:error, Ecto.Changeset.t()}
60-
def archive(%Session{} = session), do: Store.update_session(session, %{status: "archived"})
62+
@spec archive(SessionRow.t()) :: {:ok, SessionRow.t()} | {:error, Ecto.Changeset.t()}
63+
def archive(%SessionRow{} = session), do: Store.update_session(session, %{status: "archived"})
6164

6265
@doc "The number of messages in a session."
6366
@spec message_count(session_id()) :: non_neg_integer()
@@ -66,4 +69,58 @@ defmodule Trinity.Sessions do
6669
@doc "Every `seq` in a session, ascending. The stress test's population."
6770
@spec seqs(session_id()) :: [pos_integer()]
6871
def seqs(session_id), do: Store.seqs(session_id)
72+
73+
## The process (slice 012)
74+
75+
alias Trinity.Sessions.{Events, Session, Supervisor}
76+
77+
@doc "Starts the session's process, or returns the running one. The row must exist."
78+
@spec start_session(session_id()) :: {:ok, pid()} | {:error, term()}
79+
def start_session(session_id), do: Supervisor.start_session(session_id)
80+
81+
@doc "Idempotent: the running pid, or a fresh process rehydrated from the database."
82+
@spec ensure_started(session_id()) :: {:ok, pid()} | {:error, term()}
83+
def ensure_started(session_id) do
84+
case whereis(session_id) do
85+
nil -> start_session(session_id)
86+
pid -> {:ok, pid}
87+
end
88+
end
89+
90+
@doc "The session's pid, if its process is running."
91+
@spec whereis(session_id()) :: pid() | nil
92+
def whereis(session_id) do
93+
case Registry.lookup(Trinity.Registry, session_id) do
94+
[{pid, _}] -> pid
95+
[] -> nil
96+
end
97+
end
98+
99+
@doc "Persists the user's message and starts a turn; refuses while a turn is in flight."
100+
@spec send_user_message(session_id(), String.t()) :: {:ok, Message.t()} | {:error, term()}
101+
def send_user_message(session_id, content) do
102+
with {:ok, pid} <- ensure_started(session_id), do: Session.send_user_message(pid, content)
103+
end
104+
105+
@doc "Stops the turn in flight, persisting what arrived as interrupted."
106+
@spec cancel_turn(session_id()) :: :ok | {:error, term()}
107+
def cancel_turn(session_id) do
108+
case whereis(session_id) do
109+
nil -> {:error, :not_running}
110+
pid -> Session.cancel_turn(pid)
111+
end
112+
end
113+
114+
@doc "The state name and a redacted view of the process's data."
115+
@spec state(session_id()) :: map() | {:error, :not_running}
116+
def state(session_id) do
117+
case whereis(session_id) do
118+
nil -> {:error, :not_running}
119+
pid -> Session.state(pid)
120+
end
121+
end
122+
123+
@doc "Subscribes the caller to the session's events on `session:<id>`."
124+
@spec subscribe(session_id()) :: :ok | {:error, term()}
125+
def subscribe(session_id), do: Events.subscribe(session_id)
69126
end

‎lib/trinity/sessions/caps.ex‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.Sessions.Caps do
4+
@moduledoc """
5+
Code-owned caps on the agent loop (M3). Slice 012. Module attributes, not configuration: no
6+
config key can raise them, and the loop function that consults them takes no cap argument.
7+
Reaching a cap is a recorded outcome and a normal return to `idle`, never a crash.
8+
"""
9+
10+
@max_turns_per_message 8
11+
@max_tokens_per_message 200_000
12+
@max_wall_ms 600_000
13+
14+
@type reason :: :max_turns | :max_tokens | :max_wall_ms
15+
16+
@doc "The caps, for the record a cap outcome writes and for tests."
17+
@spec limits() :: %{
18+
max_turns: pos_integer(),
19+
max_tokens: pos_integer(),
20+
max_wall_ms: pos_integer()
21+
}
22+
def limits,
23+
do: %{
24+
max_turns: @max_turns_per_message,
25+
max_tokens: @max_tokens_per_message,
26+
max_wall_ms: @max_wall_ms
27+
}
28+
29+
@doc """
30+
`:ok` when the turn may continue, or the first cap it has reached. Takes the turn record only:
31+
there is no argument through which a caller could pass a looser limit.
32+
"""
33+
@spec check(Trinity.Sessions.State.turn()) :: :ok | {:cap, reason()}
34+
def check(%{turns: turns, tokens: tokens, started_at: started_at}) do
35+
cond do
36+
turns >= @max_turns_per_message -> {:cap, :max_turns}
37+
tokens >= @max_tokens_per_message -> {:cap, :max_tokens}
38+
System.monotonic_time(:millisecond) - started_at >= @max_wall_ms -> {:cap, :max_wall_ms}
39+
true -> :ok
40+
end
41+
end
42+
end

‎lib/trinity/sessions/events.ex‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.Sessions.Events do
4+
@moduledoc """
5+
The seven event shapes a Session broadcasts on `session:<id>`, and nothing else. Slice 012.
6+
Slice 013's UI and slice 070's gateways subscribe here. `broadcast/2` refuses a shape that is
7+
not one of the seven, so a new event is a change to this file first.
8+
"""
9+
10+
alias Trinity.Sessions.Message
11+
12+
@type t ::
13+
{:user_message, Message.t()}
14+
| {:assistant_delta, String.t()}
15+
| {:assistant_message, Message.t()}
16+
| {:tool_call, map()}
17+
| {:state, atom()}
18+
| {:turn_interrupted, Message.t()}
19+
| {:error, term()}
20+
21+
@doc "The PubSub topic for a session."
22+
@spec topic(String.t()) :: String.t()
23+
def topic(session_id), do: "session:" <> session_id
24+
25+
@doc "True for exactly the seven shapes."
26+
@spec valid?(term()) :: boolean()
27+
def valid?({:user_message, %Message{}}), do: true
28+
def valid?({:assistant_delta, s}) when is_binary(s), do: true
29+
def valid?({:assistant_message, %Message{}}), do: true
30+
def valid?({:tool_call, %{id: _, name: _}}), do: true
31+
def valid?({:state, s}) when is_atom(s), do: true
32+
def valid?({:turn_interrupted, %Message{}}), do: true
33+
def valid?({:error, _}), do: true
34+
def valid?(_), do: false
35+
36+
@doc "Broadcasts one event; raises on a shape that is not one of the seven."
37+
@spec broadcast(String.t(), t()) :: :ok
38+
def broadcast(session_id, event) do
39+
if valid?(event) do
40+
Phoenix.PubSub.broadcast(Trinity.PubSub, topic(session_id), {:session, session_id, event})
41+
else
42+
raise ArgumentError, "not a session event: #{inspect(event)}"
43+
end
44+
end
45+
46+
@doc "Subscribes the calling process to a session's events."
47+
@spec subscribe(String.t()) :: :ok | {:error, term()}
48+
def subscribe(session_id), do: Phoenix.PubSub.subscribe(Trinity.PubSub, topic(session_id))
49+
end

0 commit comments

Comments
 (0)