Decide what a coder session persists, and give the transcript a cursor

acf1cdf6ff0e · AtlantisPleb · · parent 1f22414d633d

Decide what a coder session persists, and give the transcript a cursor

Two questions were open: what a client writes into a thread's transcript, and
how it gets back to one. Both turn out to be questions about limits.

A payload is capped at 16,384 bytes and a listing returns at most fifty events.
Measured against four real coder sessions from one afternoon, the payload cap is
not the binding one — the largest tool result observed was 8.4 KB, and the model
transcript already bounds a result to 4,000 characters, so the same bound keeps
every event well inside it. The listing cap is: turn-level persistence fits in
fifty with room to spare, and tool-level passes it on an ordinary session of
thirteen turns and forty-two tool calls.

So `list_events/2` takes an `:after` cursor and the route publishes each event's
id. A history that cannot be read back is not persistence, and this was the one
server change the decision required.

What a session records: `turn.user`, one `tool.ran` per call carrying the
bounded result, and `turn.assistant` with the turn's usage. Call and result are
one event rather than two — they are one fact, and splitting them doubles the
count against the cap for nothing. Deltas and reasoning are not recorded: deltas
are how a reply arrives rather than what it is, and reasoning is display-only
for the same reason it is absent from the model transcript. This is roughly
ATIF's granularity, which is deliberate, since `/export` already writes a
session that way and the two should not disagree about what a session was.

Resume follows Codex, whose shape this vocabulary already follows: no argument
shows recent threads filtered to the repository, a uuid takes one directly,
`--last` skips the picker, `--all` drops the filter. `GET /api/v3/threads` is
the list and the events route is the transcript; neither needs anything new.

The audit records one question deliberately left open: what a resumed session
sends to the model. A transcript is evidence rather than a provider-shaped chat
history, and replaying a long one verbatim would reintroduce the context problem
that bounding tool results just solved.

4153 tests pass.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 290 · 2026-08-24T18:27:18.278229Z

Changed files

  • modified docs/2026-08-24-coder-account-integration-audit.md
  • modified docs/forge-exit-rehearsals.md
  • modified lib/openagents/threads.ex
  • modified lib/openagents_web/controllers/thread_controller.ex
  • modified test/openagents/forge/sync_test.exs
  • modified test/openagents_web/controllers/thread_controller_test.exs

Diff

6 files changed, +171 -18

docs/2026-08-24-coder-account-integration-audit.md modified +95 -5

@@ -3,7 +3,8 @@

3 3
**Date:** 2026-08-24
4 4
**Commit measured:** `a9a251e` on `openagents/main` (the forge), with the CLI at
5 5
`94e4cad8e7` on `OpenAgentsInc/openagents`
6
**Status:** audit and initial direction; nothing here is built yet
6
**Status:** audit and direction. The transcript routes and the event cursor are
7
built and shipped; the client that writes to them is not.
7 8
**Question:** `openagents coder` works with no account at all. We want the
8 9
opposite posture — sign in on open, work inside the account, and have the work
9 10
count. What already exists on the server to hang that on, what exists but is

@@ -124,16 +125,105 @@ costs nothing. What costs something is deciding what happens when the login is

124 125
declined: refuse to start, start in a named local mode, or start and nag. My
125 126
view is the second, and that the nag is the leaderboard being visible and empty.
126 127
128
## Persistence: what the store can actually hold
129
130
The coder keeps nothing. A session ends and its history goes with it, and the
131
audit that preceded this one proposed a local JSON Lines file keyed on a thread
132
id. That is the wrong shape now: `thread_events` already exists, and a local
133
file beside it is a second copy that can disagree with the first. The server's
134
copy should be the only copy.
135
136
Three routes now open it — `GET /api/v3/threads`,
137
`GET /api/v3/threads/{id}/events`, and `POST /api/v3/threads/{id}/events` — all
138
wrapping context functions that were already there. What remains is deciding
139
what a client writes into them, and that is a question about limits rather than
140
about taste.
141
142
### The two limits
143
144
**A payload is 2 to 16,384 bytes**, enforced by
145
`thread_events_payload_bound_check` on `octet_length(payload::text)`.
146
147
**A listing returns at most 50 events**, `@maximum_listed` in
148
`OpenAgents.Threads`.
149
150
Measured against four real coder sessions from the same afternoon:
151
152
| Session | Turns | Tool calls | Events at turn level | Events at tool level | Largest tool result |
153
| --- | --- | --- | --- | --- | --- |
154
| `16-58-34` | 13 | 42 | 13 | 55 | 6,511 B |
155
| `17-04-23` | 16 | 45 | 16 | 61 | 6,511 B |
156
| `17-08-32` | 4 | 5 | 4 | 9 | 8,418 B |
157
| `17-24-37` | 6 | 0 | 6 | 6 | 0 B |
158
159
The payload cap is not the binding constraint: the largest tool result observed
160
was 8.4 KB, and the transcript already bounds a result to 4,000 characters
161
before it reaches a model, so the same bound applied to an event keeps every one
162
of them comfortably inside 16 KB. A 30 KB shell output would not fit, which is
163
why the bound is applied rather than assumed.
164
165
**The listing cap was the binding constraint.** Turn-level persistence fits
166
inside fifty with room to spare; tool-level passes it on an ordinary working
167
session. So `list_events/2` now takes an `:after` cursor and the route publishes
168
each event's id, because a history that cannot be read back is not persistence.
169
That is the one server change this section required, and it is done.
170
171
### What to record
172
173
Recorded, in the order they happen:
174
175
- `turn.user` — what the reader asked.
176
- `tool.ran` — one event per call, carrying the tool, its arguments, and its
177
  bounded result. Call and result are one event rather than two: they are one
178
  fact, and splitting them doubles the count against the cap for nothing.
179
- `turn.assistant` — the answer, with the turn's token usage and call count.
180
181
Not recorded: text deltas, reasoning deltas, and the interface's own notices.
182
Deltas are how a reply arrives, not what it is, and a transcript that stores the
183
arrival cannot be read back as the thing. Reasoning is display-only for the same
184
reason it is not on the model transcript. Notices never reached a model.
185
186
That is roughly ATIF's step shape, which is not a coincidence: `/export` already
187
writes a session in exactly this granularity, and the two should not disagree
188
about what a session was.
189
190
### Resume
191
192
Follow Codex, which has the shape right and which this vocabulary already
193
follows:
194
195
- `openagents coder --resume` with no argument shows recent threads and lets one
196
  be picked, filtered to the current repository by default.
197
- `openagents coder --resume <uuid>` takes a thread id directly.
198
- `openagents coder --resume --last` continues the most recent without asking.
199
- `--all` drops the repository filter and shows which repository each thread
200
  belongs to.
201
202
`GET /api/v3/threads` is the picker's list and `GET /api/v3/threads/{id}/events`
203
is the transcript it replays. Neither needs anything new.
204
205
The one open question is what a resumed session sends to the model. The
206
transcript is evidence of what happened, not a chat history in a provider's
207
shape, so replaying it verbatim is not obviously right — and a long thread would
208
reintroduce exactly the context-size problem that bounding tool results just
209
solved. My instinct is that a resumed session replays the turns and the tool
210
results, not the deltas, and bounds them the same way a live session does.
211
127 212
## What I would do first
128 213
129 214
1. **Put thread spend on the leaderboard.** One arm on an existing union, with
130 215
   the double-count question answered against `DATA-002`. Nothing else on this
131 216
   list is honest until this is done.
132
2. **Write a coder session's outcome into experience memory, keyed by issue.**
217
2. **Write the coder's turns to `thread_events`, and resume from them.** The
218
   routes and the cursor exist; what remains is the client. This is also what
219
   makes a session worth signing in for, since the history stops being local and
220
   disposable.
221
3. **Write a coder session's outcome into experience memory, keyed by issue.**
133 222
   The ATIF export already holds it; the join is the work.
134
3. **Then** force the login, because by then it buys the board and the memory.
135
4. **Then** ask what a score should measure, with the data from 1 and 2 to
136
   answer it rather than guess.
223
4. **Then** force the login, because by then it buys the board, the history, and
224
   the memory.
225
5. **Then** ask what a score should measure, with the data to answer it rather
226
   than guess.
137 227
138 228
## What this document does not claim
139 229
docs/forge-exit-rehearsals.md modified +7 -2

@@ -182,8 +182,13 @@ reported rather than reconciled silently.

182 182
   bin/openagents rpc 'OpenAgents.Forge.Sync.rebuild("{storage_key}")'
183 183
   ```
184 184
185
   The rebuild path takes no mirror input. `EXIT-003` turns red if one is
186
   added.
185
   `rebuild/1` discards the local bare-repository projection and
186
   re-materializes it from the WAL, from sequence zero, with no mirror input.
187
   That is the recovery a wrong or damaged projection needs, unlike
188
   `ensure_fresh/1` and `replay_missing/3`, which trust the projection's
189
   applied-sequence marker and only replay what it has not yet applied. It
190
   returns `:ok` on success and a typed error when the WAL cannot produce a
191
   servable projection. `EXIT-003` turns red if a mirror input is added.
187 192
188 193
## 4. Key rotation
189 194
lib/openagents/threads.ex modified +23 -1

@@ -192,7 +192,14 @@ defmodule OpenAgents.Threads do

192 192
    |> Repo.all()
193 193
  end
194 194
195
  @doc "The thread's transcript, oldest first, bounded."
195
  @doc """
196
  The thread's transcript, oldest first, bounded.
197
198
  `:after` continues from an event id already read. Without it a transcript
199
  longer than the cap could not be read back at all, and a session's history is
200
  exactly the thing that outgrows a cap: a working session records a turn and
201
  every tool it ran, which passes fifty inside an hour.
202
  """
196 203
  @spec list_events(Thread.t(), keyword()) :: [Event.t()]
197 204
  def list_events(%Thread{id: thread_id}, options \\ []) do
198 205
    limit = options |> Keyword.get(:limit, @maximum_listed) |> min(@maximum_listed) |> max(1)

@@ -202,9 +209,24 @@ defmodule OpenAgents.Threads do

202 209
      order_by: [asc: e.id],
203 210
      limit: ^limit
204 211
    )
212
    |> after_event(Keyword.get(options, :after))
205 213
    |> Repo.all()
206 214
  end
207 215
216
  defp after_event(query, nil), do: query
217
218
  defp after_event(query, after_id) when is_integer(after_id),
219
    do: from(e in query, where: e.id > ^after_id)
220
221
  defp after_event(query, after_id) when is_binary(after_id) do
222
    case Integer.parse(after_id) do
223
      {id, ""} -> after_event(query, id)
224
      _unparsed -> query
225
    end
226
  end
227
228
  defp after_event(query, _other), do: query
229
208 230
  @doc """
209 231
  Append one bounded event to a thread's transcript and advance its counter.
210 232
lib/openagents_web/controllers/thread_controller.ex modified +11

@@ -242,6 +242,14 @@ defmodule OpenAgentsWeb.ThreadController do

242 242
      _absent ->
243 243
        []
244 244
    end
245
    |> continue_from(params)
246
  end
247
248
  defp continue_from(options, params) do
249
    case Map.get(params, "after") do
250
      value when is_binary(value) -> Keyword.put(options, :after, value)
251
      _absent -> options
252
    end
245 253
  end
246 254
247 255
  defp event_parameters(params) do

@@ -326,8 +334,11 @@ defmodule OpenAgentsWeb.ThreadController do

326 334
  # the grant's is published: it is the one the request will actually use, and
327 335
  # printing the same name twice invites a reader to think they can differ.
328 336
337
  # The id is published because it is the cursor: a client continues from the
338
  # last one it read rather than counting.
329 339
  defp event_view(event) do
330 340
    %{
341
      "id" => event.id,
331 342
      "schema" => event.schema,
332 343
      "event_type" => event.event_type,
333 344
      "payload" => event.payload,
test/openagents/forge/sync_test.exs modified +12 -10

@@ -205,10 +205,10 @@ defmodule OpenAgents.Forge.SyncTest do

205 205
             )
206 206
  end
207 207
208
  test "rebuild re-materializes the projection from sequence zero and preserves the head", %{
208
  test "rebuild is the operator's from-sequence-zero recovery entry point", %{
209 209
    root: root
210 210
  } do
211
    {index, sha} = put_bundle_entry!(root, "rebuild-repo", "trunk")
211
    {index, _sha} = put_bundle_entry!(root, "rebuild-repo", "trunk")
212 212
    assert :ok = Sync.ensure_fresh("rebuild-repo", "trunk")
213 213
214 214
    # Append a second commit so the WAL holds more than the trivial first

@@ -236,27 +236,29 @@ defmodule OpenAgents.Forge.SyncTest do

236 236
237 237
    {:ok, generation, _} = WAL.read_index("rebuild-repo")
238 238
    {:ok, _} = WAL.cas_index("rebuild-repo", generation, WAL.append_entry(index, entry))
239
    assert :ok = Sync.ensure_fresh("rebuild-repo", "trunk")
240 239
241 240
    bare_path = Repos.bare_path("rebuild-repo")
242
    assert String.trim(git_bare!(bare_path, ["rev-parse", "trunk"])) == sha2
243 241
244
    # Rebuild is the operator recovery for a *wrong* projection: discard and
245
    # re-materialize from sequence zero. It must land on the same head.
242
    # Discard the local projection entirely. The operator recovery the
243
    # runbooks name is `OpenAgents.Forge.Sync.rebuild/1`: re-materialize the
244
    # repository from the WAL, from sequence zero, with no mirror input.
245
    File.rm_rf!(bare_path)
246
246 247
    assert :ok = Sync.rebuild("rebuild-repo", "trunk")
247 248
248 249
    assert String.trim(git_bare!(bare_path, ["rev-parse", "trunk"])) == sha2
249 250
    assert Repos.refs("rebuild-repo") == refs
251
    assert String.trim(git_bare!(bare_path, ["show", "trunk:README.md"])) == "durable import"
250 252
    assert String.trim(git_bare!(bare_path, ["show", "trunk:second.md"])) == "second commit"
253
    assert String.trim(git_bare!(bare_path, ["symbolic-ref", "HEAD"])) == "refs/heads/trunk"
251 254
252
    # The one-argument form the runbooks name resolves the default branch the
253
    # same way ensure_fresh/1 does, so `Sync.rebuild("{storage_key}")` runs.
255
    # The one-argument form the runbooks name uses the "main" default the same
256
    # way ensure_fresh/1 does, so `Sync.rebuild("{storage_key}")` runs.
254 257
    assert :ok = Sync.rebuild("rebuild-repo")
255 258
    assert String.trim(git_bare!(bare_path, ["rev-parse", "trunk"])) == sha2
256
    assert String.trim(git_bare!(bare_path, ["symbolic-ref", "HEAD"])) == "refs/heads/trunk"
257 259
  end
258 260
259
261
  defp git!(directory, args) do
260 262
    {output, 0} = System.cmd("git", args, cd: directory, stderr_to_stdout: true)
261 263
    output
262 264
  end
test/openagents_web/controllers/thread_controller_test.exs modified +23

@@ -548,6 +548,29 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

548 548
      assert Enum.all?(body["events"], &(&1["schema"] == "openagents.thread.event.v1"))
549 549
    end
550 550
551
    test "continues from an event already read", %{authenticated: conn, id: id} do
552
      for text <- ["one", "two", "three"] do
553
        conn
554
        |> post(~p"/api/v3/threads/#{id}/events", %{
555
          "event_type" => "turn.user",
556
          "payload" => %{"text" => text}
557
        })
558
        |> json_response(201)
559
      end
560
561
      first = conn |> get(~p"/api/v3/threads/#{id}/events?limit=2") |> json_response(200)
562
      cursor = List.last(first["events"])["id"]
563
564
      rest = conn |> get(~p"/api/v3/threads/#{id}/events?after=#{cursor}") |> json_response(200)
565
566
      # A working session records a turn and every tool it ran, which passes
567
      # the listing cap inside an hour. Without a cursor its history could not
568
      # be read back at all.
569
      assert length(first["events"]) == 2
570
      assert Enum.all?(rest["events"], &(&1["id"] > cursor))
571
      assert Enum.map(rest["events"], & &1["payload"]["text"]) |> List.last() == "three"
572
    end
573
551 574
    test "refuses an event with no type", %{authenticated: conn, id: id} do
552 575
      body =
553 576
        conn

This page updates live while a promote is in flight · changelog