Skip to content

M2-12 Agent sessions per thread

No Date Ruling Reason
D-M2-12-0 2026-10-22 Built on the old integration line and ported to main after M2-08 and M2-09 landed: only the ticket's own commits were brought over. The migrations are renumbered to 00069 and 00070, because main already uses 00057 to 00068 for the commands of M2-16, the push registrations of M3-19, the follow-ups of M2-11, the components of M2-15, the imports of M8-03 and the attachments of M3-07, and the two migrations of the old line that added agent_session.resume_failed to messages.system_code are dropped, because 00032 on main already allows the code; the additions to the German chapter files of the old base go to their English files on main; the wiring moves into module_runs.go (PL/28); generated code is regenerated, never merged. The rules of the landing replace the integration branches; main is the reference.
D-M2-12-1 2026-10-21 agents declares the source RunSource (ActiveRun: the run of the thread in queued, running or waiting_input, its row locked FOR UPDATE), which runs implements in its postgres adapter and wire wires, and publishes the In-Tx event SessionAttached; runs.OnSessionAttached sets runs.agent_session_id and, with BindFailed, writes the system line through messaging.Messages.AppendSystemLine. A report must find the reporting agent's active run and runs.agent_session_id belongs to runs (03), while agents may not use runs (02 4.4); a declared source and an In-Tx event keep every edge downward, as the authz sources and agents.AgentDisabled do.
D-M2-12-2 2026-10-21 listAgentSessions returns every row of the thread, sorted by created_at DESC, id DESC, unpaginated (AgentSessionList has only items); 06 section 6 point 6 lists it. The sessions of one thread are a small list in the sense of 06 section 6 point 6.
D-M2-12-3 2026-10-21 agent_session.reset and agent_session.bind are Level(trigger) plus the agent.invoke guard with InvokeFacts{AgentID: operator, TriggerOrigin: human, MentionsAgent: true} and no active run agent; a thread without operator denies with agent_not_reachable. The thread in which the reference is current is the second target authorize.Resource.TargetThreadID of agent_session.bind: authz checks visibility and the same requirement there, and an invisible target answers the 404 of an unknown thread; 07 4.2 lists the action among those with two targets. A session switch addresses the thread's operator explicitly, so the mention rule of 05 section 5 point 3 counts as met, and it starts no run, so point 8 does not apply; 09 9.4 point 1 checks the permission "also there", and 07 4.2 forbids a second check in the use case.
D-M2-12-4 2026-10-21 A reset without a current session answers 409 conflict with reason no_current_session. A reset during an active run ends the current row at once and leaves the run alone: the run keeps runs.agent_session_id, its reports still reach that row, and the next run gets new (09 9.2). There is nothing to end, and a 201 without a session has no body that fits AgentSession; 03 has no column for a pending reset, and "waits until it ends" means the active run is not switched, with the same visible effect.
D-M2-12-5 2026-10-21 An import row binds only when the thread has no row other than rows with end_reason = 'import'; the newest import row of the operator (created_at, then id) is copied into a current row with started = true in the transaction that appends agent.invocation, audited with cause import. session_mode is the openapi name of session.mode (09 9.1); runs/domain decides the mode from the row, started and whether a run used the row. "Has had no current session since the import" (09 9.5 point 5) must hold also after a reset; only runs knows from runs.agent_session_id whether a run used a row.
D-M2-12-6 2026-10-21 run.start (queued -> running) attaches the current row of the run's agent unchanged, else the most recent row of this thread and agent with the given reference, else creates a current row with started = false at version 1; without a reference it attaches nothing; a guest run attaches nothing. resumeRun attaches nothing. An invalid reference fails startRun with 400 validation_failed at /external_session_ref. run.start never changes an existing row, so the version the adapter got with the payload stays valid for its first If-Match; a changed reference or model comes only through agent_session.report.
D-M2-12-7 2026-10-21 If-Match (IfMatchOptional) is the compare-and-swap of reportAgentSession: absent means "this run has no session row yet", so a missing header where a row exists is 412 version_conflict with current, not 428; started only moves forward; a different reference replaces the stored one of the same row. New reasons session_not_bound and no_current_session are documented in 09. A reference change of a started row is the same session under a resolved reference (09 9.5 point 3, 18.9 "Session missing in Claude Code"); the locked run row makes the second of two concurrent first reports see the first one's row.
D-M2-12-8 2026-10-21 bind_failed: session_missing on a row that a reset or bind ended during the run inserts the new row already ended, with that row's end_reason and ended_at = now(). The human decision stays in force (03 ended_at: "or was never current"); the author and reopen rule of the system line are in 03 (R-DM-42).
D-M2-12-9 2026-10-21 New server key agent.session_ref_max_bytes (default 1024, minimum 1, maximum 2048); external_session_ref of bind, report and run.start is not empty or white space only (required), passes textcheck.DisplayText and is at most that many bytes (too_long), stored as sent; model follows the pattern of default_model (09 2.2). Without a limit an oversized reference ends in a database error and a 500 (rule 9 needs a named key); the maximum keeps an entry of the unique index on (agent_id, external_session_ref) within the PostgreSQL B-tree index entry limit; the reference is shown in the session column (SEC-IN-4).
D-M2-12-10 2026-10-21 A bind sets the new row's cost_baseline_micros to cost_baseline_micros + cumulative_cost_micros of the most recent row of the same agent and reference in any thread, with its currency (none: 0 and the default currency); an import binding copies baseline and currency of the import row; rows of run.start, a report or bind_failed start at 0. A runtime that reports sums per session would otherwise book the earlier amount a second time after a rebind (03: the server books only the part above it); booking itself is M2-22.
D-M2-12-11 2026-10-21 agent_session.changed is appended for every committed change of a row (insert, report change, end), ended rows first in ascending id, then the new row; actor of changes by ForInvocation is the triggering person. One allow audit entry per agent_session.bind (explicit or import) with session_id, ended_session_id, taken_over_session_id, taken_over_thread_id, take_over and cause, never the reference; reset, read and report write none. The 05 note "new session, rebinding, binding after an import" names examples; model and started changes are announced too, so the session column stays current; SEC-AUD-5 allows IDs and codes only.
D-M2-12-12 2026-10-21 The generated clients (sdk/python, sdk/typescript, packages/api_client) are regenerated from the changed openapi. make codegen-check holds the clients equal to the contract.
D-M2-12-13 2026-10-21 A nil Sessions in the deps of runs gives every operator run session_mode = new without a row. Test worlds without the sessions module keep working; production wiring always sets it.
D-M2-12-14 2026-10-21 runs.agent_session_id is written without a change of runs.version and without an event; the run document shows it at the next read. The brief names no event for it, and the session itself is announced by agent_session.changed.
D-M2-12-15 2026-10-21 The invocation of an operator run records the row it names in runs.agent_session_id at the acceptance, and run.start keeps that row (design note 10 refined): a row a run was invoked with is attached unchanged, also after a reset or bind ended it while the run was queued; a run invoked without a row attaches only a row it creates from its reference, never a row bound while it was queued. A row counts as used for resume only through a run that started with it. Code review of M2-12: the literal rule "a current row of this agent: attach it" moved a queued run onto a row a person bound with /resume, so the next run got resume instead of bind and the adapter's report could overwrite the bound reference; design note 8 h and 09 9.4 require that a bind during an active run (queued is active, R-DM-14) leaves that run on its row.
D-M2-12-16 2026-10-21 model of agent_session.report and run.start is bounded by agent.session_ref_max_bytes too (400 validation_failed, code too_long on /model). Code review of M2-12: the model is shown in the session column and carried in every agent.invocation of the thread; an unbounded one pushed those events past sync.event_payload_max_bytes, so every triggering message of the thread failed with 500 until /new. The reference key already bounds the other text of the same row, so no new key is needed (rule 9).
D-M2-12-17 2026-10-21 bindAgentSession looks for the thread in which the reference is current only when the reference passes its validation; an invalid reference is current nowhere, the permission is decided first and the validation answers 400 after it. Code review of M2-12: a NUL in the reference reached PostgreSQL before the permission check and gave 500 on an existing thread and 404 on an unknown one, an existence oracle against R-AZ-01.
D-M2-12-18 2026-10-21 The row that replaces a row after bind_failed starts at the replaced row's version plus one, not at version 1. Design note 12 requires "exactly once" under a concurrent duplicate; with version 1 the duplicate's If-Match of a replaced row at version 1 would pass against the new row and write a second system line.
D-M2-12-19 2026-10-21 resetAgentSession and the session decision of a new run read the current row again when the locking read found none, and lock again; 409 conflict with reason no_current_session only when no row is current. Red team of M2-12: under READ COMMITTED a reset that waited on the lock of a row a concurrent bind_failed or bind ended skipped it and did not see the replacing row, so it answered 409 while a row was current and the /new was lost.
D-M2-12-20 2026-10-21 startRun and resumeRun validate model (pattern of default_model, agent.session_ref_max_bytes) through agents.Sessions.CheckStartModel before the transition stores it, for every run, with or without a reference. Red team of M2-12: the transition wrote runs.model before any check, so a NUL gave 500 and an unchecked model reached the run document; design note 13 and D-M2-12-16 bound the model of run.start.
D-M2-12-21 2026-10-22 The agent.invocation of a command run (M2-16) decides its session like a message run: emitCommandInvocation calls the same invocationSession with the command owner as agent and the invoker as triggering person, so an operator command run carries session_mode and, where a row exists, agent_session, and a guest command run carries neither; 09 9.1 says so. InvocationPayload takes the session as a required argument (design note 9), and the M2-16 brief names M2-12 as the ticket that adds agent_session to the command payload through the same builder; without it the runtime of an operator command run could not resume the thread's session.