Contents

The Terret Long-Lived Agent Lifecycle (v1)

M0–M5 proved a session that runs one turn, or a few, end to end: the log, the loop, the socket, MCP tools. This document covers what changes when a session runs for weeks instead of minutes (plan §12): many turns, many wakes, and a deploy somewhere in between. Everything below — durable approvals, turn resumption, compaction, titling, cost accounting, and the agent registry’s lifecycle — exists so that derived context survives all three. None of it is a new execution path; it is the existing log-first contract (docs/terret-implementation-plan.md §2, “model-visible means logged”) applied to a longer clock.

What “long-lived” means here

A short-lived session lives inside one process and is done before anyone asks whether it survives a restart. A long-lived session is measured in weeks: it accumulates turns past what a model’s context window holds, it sits idle between wakes, and at some point the process serving it dies and a new one takes over. The mechanisms in this document answer three questions a short-lived session never has to: what does the model see once history no longer fits (compaction), what happens to a human approval that was asked but not yet answered when the process died (durable approvals plus turn resumption), and what does a session cost and call itself over that whole span (cost accounting, titling). The agent registry’s lifecycle is the bookkeeping that makes many such sessions livable in one process at once.

The status machine

idle → running → waiting_approval → running → idle

:waiting_approval is not a peer of :running; it is a sub-state of a turn. The agent is still mid-turn — a fiber is parked inside the tools pipeline waiting on a verdict — and a parked agent refuses a new turn exactly like a running one does. Plan §6.4 also names waiting_input, stopping, and done/failed. Those arrive with M7/M8 work; this milestone builds only idle, running, and waiting_approval.

A turn is a bounded number of steps: Loop::MAX_STEPS is 25, and a turn that would log a 26th raises rather than looping on a model that will not stop calling tools. Every turn closes with a durable turn/end {status}, and the status is one of five: completed (nothing more is owed), cancelled (a cancel was honored at a step boundary), rejected (an agent/pre_step listener refused the claim), empty (the turn had nothing to say — no input, no steer, no owed call), or failed (an exception left the turn). The one case with no turn/end at all is a failed resume, which deliberately leaves its turn open; see “Resuming an open turn”.

Appends and fan-out

Everything below hangs off session/event listeners, several of which append while handling an event — the compactor and the titler both react to turn/end by writing to the same log. Two properties make that safe. Seq assignment, the durable write, and the in-memory push are one critical section per session, so two appenders (a connection’s frame and a turn, say) can never claim the same seq even though the store write yields. And fan-out is queued rather than nested: an event a listener appends is delivered after the event it reacted to, never before, so a subscriber sees the log in the order it was written. The price, worth naming: a listener’s own append returns before that event has fanned out.

Durable approvals

A tool Definition’s approval: field (docs/terret-implementation-plan.md §6.3) is :never, :policy, or :always, default :never. ctx[:approvals] is the middleware that consumes it: :always always asks; :policy asks when the definition is mutating: (plan §13’s spirit — mutation is what needs a human under policy); :never passes the call straight through.

The gate lives on tools/execute, not tools/pre_execute. Waterfalls dispatch parent-first, so a tools/pre_execute veto — the per-agent AllowList (docs/mcp.md) — always settles a call before a human is asked; putting the approvals gate at pre_execute on the root context would have it run ahead of that per-agent veto instead. Durable approvals are an opt-in row (a tool’s approval: field), and Terret’s primary workload — autonomous agentic systems — mostly skips them in favor of the policy-as-code allow list below.

Parking a call appends durable approval/requested {call_id, name, args}. Resolving one appends durable approval/resolved {call_id, verdict, reason?} — the same event the socket’s approve/deny frames land on (docs/protocol.md). The parked fiber resumes on the in-process fan-out of that append; ctx[:approvals].pending(session_id) lists the call ids still awaiting a verdict, which is what a reconnecting client, and resume_turn, need in order to find outstanding asks.

An approval belongs to the turn that asked for it. pending and the gate’s verdict lookup both read only the open turn — the events after the last turn/start, and nothing at all once a turn/end follows it — and the lookup is bound to content as well as to the id: the verdict must follow a request in that turn naming the same call id, tool, and arguments. Provider tool call ids are not contractually unique, so without both scopings an id reused in a later turn would silently inherit a decision a human made about something else. A closed turn’s approvals settle with the turn; a call that comes back afterwards is asked about again.

Both sides of an approval are in the log, so a parked call survives a restart. On resume, the gate re-reads the log: if a verdict is already recorded it never parks again; if none is recorded yet, the open turn sits resumable until Loop#resume_turn re-enters it the moment a verdict lands.

There is no timeout. A parked approval is parked until a human decides, by design. deny_pending! is the escape hatch: cancelling a turn while approvals are parked marks the turn cancelled first and only then denies every standing request durably, so the parked call unparks into a turn that already knows it is stopping — and a cancelled turn never leaves an approval dangling for a future resume to trip over.

Resuming an open turn

Loop#resumable?(session_id) is true when the log has a turn/start with no turn/end after it — the signature of a turn a process died in the middle of. resume_turn does not append a second turn/start: it treats the existing turn as still open.

Resuming is the only way back into such a session. run_turn on an idle agent whose log holds an open turn raises TurnOpenInLog and appends nothing: a second turn/start would strand whatever the open turn owes (resumable? reads from the last turn/start) and leave the projection carrying an assistant tool call with no result — which a real provider rejects outright, on every request, forever. Every caller that can meet a resumed session therefore branches: resumable means inject the new text and resume_turn; otherwise run_turn. The socket does this on a waking inject (docs/protocol.md), and so does examples/web_chat.rb. An agent that is already mid-turn is a different matter and still raises the older TurnAlreadyRunning — that open turn is its own, and the wake race below depends on that distinction.

It first closes the open step: any tool call owed by the last assistant message that has no matching tool/result yet gets re-executed (reading approval verdicts from the log rather than re-asking), then the step’s step/end is appended — without a usage: field, because the original process’s usage figure died with it. From there the turn continues stepping normally and closes with an ordinary turn/end.

That re-execution is why crash recovery is at-least-once for tool calls: a call whose tool/result never logged may still have run, in whole or in part, before the process died, and resume runs it again. Idempotency is the tool’s concern — a tool that cannot be safely repeated needs its own guard. gems/terret-ws/test/lifecycle_test.rb holds that lane honest: a subprocess wedges mid-tool, dies by kill -9 so no ensure runs, and a fresh process completes the turn on the first wake.

Three edges are left visible rather than papered over:

  • An unclosed step/start from a mid-step crash stays unclosed; step numbering continues past the gap rather than backfilling it.
  • A turn that crashed right after a final, tool-free assistant message resumes by making one extra model request — the model sees its own prior message in its history and is asked to continue, which in practice means wrapping up.
  • A turn that crashed before its first step logged anything closes as :empty on resume: the input that triggered it was never durably logged, so there is nothing to recover.

A resume that fails — the model provider is down, say — leaves the turn open rather than closing it failed. That is the deliberate difference from run_turn, where a failure is terminal for the turn and the log says so. A resumed turn still owes a tool call; closing it would strand that call permanently for what is usually a transient outage, so the turn stays resumable and the next stimulus picks it up again.

Compaction

session/compacted {upto_seq, summary} is a durable, model-visible event (plan §2.5). Sessions#derive_messages (gems/terret-core/lib/terret/sessions.rb) projects it by replacing every event at or before upto_seq with the summary as a single user message; if more than one compaction exists in the log, only the latest is applied — superseded ones simply drop out of the projection.

The boundary contract: upto_seq is always the seq immediately preceding the session/compacted event itself, computed at append time — after the summarizer has already returned. Summarizing is a round trip, so the compactor records the last seq before it starts and checks again after: if anything model-visible landed meanwhile (user/message, context/injected, assistant/message, tool/result, session/compacted), it declines and appends no boundary, because that history would otherwise be swept under a summary that never read it. Projection-invisible arrivals — a raced approval/resolved, a policy/updated — still fall under the boundary and lose nothing, since they were never part of the projection the summary stands in for.

Compaction is a between-turns operation. The trigger owns the safe window: it fires on turn/end, when the agent is idle and no step is mid-flight. compact! called by hand mid-turn is not safe in the same way — the running turn’s own next assistant/message is exactly the kind of model-visible event the check above will refuse on, so a manual compaction racing a live turn declines rather than corrupting anything, but it also does not accomplish what the caller asked for.

Triggering is automatic and manual both. ctx[:compactor], configured with config[:budget], compacts a session after any turn whose last step/end carried usage.prompt_tokens >= budget. compact!(session_id) is the same operation invoked directly. Either way, generating the summary itself is a seam, ctx[:summarizer] — sole-provider, like the session store — rather than something the compactor does inline. RoleSummarizer, the no-signup default, issues one utility request through ctx[:llm].stream derived from the log — not a session request the loop’s invariant assert ever sees (docs/terret-implementation-plan.md §2). terret-morph is the other provider: it calls out to Morph’s Compact API on the wire proven in the deployed agora integration, extractive-compressing the rendered history — every message part on its own role-tagged line, tool calls and results included, so a compacted session keeps the deploy ids and errors its transcript earned — instead of asking a model to write a summary.

The two providers fail differently, and the difference is worth knowing. Morph declines to nil on every failure, warning as it goes: no key, an HTTP error, a torn response. RoleSummarizer raises instead when its :compactor role is unconfigured — inside the budget trigger that is isolated by emit dispatch and the turn survives untouched, but a manual compact! raises it through to the caller. Either way a decline is non-fatal: the turn that triggered it already closed successfully, and the next overweight turn simply retries compaction.

Titling

Every session gets exactly one durable session/titled {title} event, appended by ctx[:titler] at the first turn/end it sees. Titling uses the :titler model role when one is configured in config[:roles]; absent that, it falls back to the session’s first user message truncated to 40 characters. Whichever produced it, the stored title is capped at 80 characters — a model asked for six words can always answer with sixty. Like an approval event, a title is metadata: it never enters derive_messages’s projection. Sessions#title(session_id) reads the latest one recorded.

Cost accounting

Usage figures arrive on step/end events — the adapter yields them, and for OpenRouter that means every final SSE chunk carries usage automatically, with no separate accounting call. Sessions#usage(session_id) sums every step/end’s usage across the whole log into {prompt_tokens:, completion_tokens:, cost:, steps:}. A session’s lifetime spend is a pure projection of the log, which is exactly why it stays correct across a restart: nothing about it depends on any one process having been alive for the whole session.

Agent lifecycle

At most one live agent exists per session at a time. spawn_agent refuses both an agent-id collision and a session already spawned under a different agent, raising AgentExists either way, and enforces config[:max_agents] (default 128), raising AgentCapExceeded once the registry is full — the blast-radius cap from plan §14’s debt list.

dispose_agent(id) disposes the agent’s forked Context — every listener and effect it registered dies with it — and frees its slot in the registry. Only an idle agent can be disposed; disposing a running or parked one would tear down the fiber a turn is depending on. agent_for_session(session_id) is the session-to-agent index: the approvals service uses it to find the right agent to flip to :waiting_approval and back.

Hot-reloadable permissions

Terret::Tools::AllowList (plan §6.3) is a per-agent tools/pre_execute listener installed on the agent’s forked context, but the pattern set it enforces is not frozen in that listener’s closure. The active set is a log projection: the patterns from the last durable policy/updated event in the call’s session, falling back to the patterns install was called with — the install-time set is a floor, not a ceiling, and it only governs sessions that have never hot-updated. A session this context cannot read at all is a third case, and it fails closed: no policy is readable, so nothing is permitted and every call is vetoed with a warning. Falling back to the floor there would hand an unknown session more authority than the floor was ever meant to grant.

AllowList.update(ctx, session_id, patterns) is an ordinary durable append. It takes effect on the very next tool call — no reinstall, no listener churn — and because it is only a log projection, replaying the session on a fresh process rebuilds it exactly: a hot-reloaded policy survives a restart for free, the same way compaction and titling do. The last policy/updated event in the log always wins; superseded ones simply stop being the one current_patterns finds.

That derivation is a reverse scan of the whole session log, and it runs on every tool call, so the active set is memoised per session in a read-through cache the install owns. A cache miss derives from the log exactly as above; a session/event listener refreshes the entry the moment a policy/updated lands. Invalidation therefore stays a function of the durable log and never becomes a second source of truth, an unknown session is never cached so its deny-all can never ossify into an allow, and the cache is a closure local of one install — a forked agent’s own AllowList caches on its own, so nothing leaks a policy across agents.

The socket drives it with the set_policy frame (docs/protocol.md), which appends policy/updated the same way set_model repoints a model role — seam-first, no bespoke wiring. Deny-by-default is unchanged: a call that matches no active pattern is a Veto, which surfaces to the model as an ordinary tool-result error rather than stopping the turn. Like an approval or a title, policy/updated is metadata — derive_messages never projects it into what the model sees.

Wake-on-stimulus

inject(text, wake: false) queues text in the agent’s inbox; it rides into a step the next time one runs, whether that step belongs to a turn already underway or one that has not started yet. wake: true on an idle agent is what actually starts a turn, with the injected text as its input.

Two wake: true injections racing on the same idle agent in one read burst produce a winner and a loser: the loser’s attempt to start a turn raises TurnAlreadyRunning, and rather than dropping its text, that text is requeued into the inbox — it rides the winner’s very next step, or the next turn if the winner’s turn ends first. The wake race requeues; it never drops.