Add recovery mechanics - #12
Conversation
…truct; add default save/2 implementations
…at can only be updated together, it now supports updating any combination of columns at once through the Payload wrapper. To achieve this Ecto was added as a dependency, so Postgres could use it for DB operations instead of raw sql queries (main benefit was in the unconstrained save/1 function where we could skip tedious string ops)
Besides looking in the registry (lookup/1) resume/2 also checks whether the process is actually alive using running?/1
Legion now supports emitting live updates from within a single LLM turn. It's configurable through implementing the optional persistence_frequency/0 callback and providing either :turn or :step, default value is :turn and it persists the state at the end of a turn (that does inclde the intra state history, but the refresh rate is slower). When using :step writes also occur after each intermediate eval result or recovorable error to ensure full recoverability is achievable in the future
Change folder structure and make migrations use Ecto
Complete the resume/2 loop by creating a mechanism where not only is an agent's history recovered, but also it actually RESUMES executing, if it was interrupted before completion (switching back to :idle).
Users can now opt in for Legion to automatically drive interrupted (:running without PID) agents to completion.
There was a problem hiding this comment.
Pull request overview
Adds an opt-in startup recovery workflow to automatically drive persisted, interrupted root runs (status: :running, parent_agent_id: nil) to completion without blocking application startup. This extends existing step-persistence and resume capabilities by introducing a dedicated recovery path (Legion.recover/2) and a temporary startup worker (Legion.Recovery).
Changes:
- Introduce
Legion.Recoverystartup worker and wire it intoLegion.Applicationvia:recoveryconfig. - Add
Legion.recover/2,AgentServer.start_monitor/2, and resume/recover execution continuation via persistedconversation_state.execution. - Update store/state encoding, docs, and tests to treat
conversation_state.executionas always present (nilwhen not checkpointing).
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| test/legion/store/postgres_test.exs | Updates persisted conversation_state fixtures to include execution: nil. |
| test/legion/store/postgres_db_test.exs | Same as above for DB-backed store tests. |
| test/legion/recovery_test.exs | Adds coverage for startup recovery scanning, filtering, and concurrency limiting. |
| test/legion/application_test.exs | Adds coverage for passing recovery config into Legion.Application.children/0. |
| test/legion/agent_server_test.exs | Adds coverage for start_monitor/2, resume checkpoints, and recover/2 behavior. |
| test/integration/step_persistence_test.exs | Aligns assertions with execution being present (as nil) in snapshots. |
| README.md | Documents :recovery configuration and Legion.recover/2 usage. |
| mix.exs | Updates source URL and includes Legion.Recovery in docs grouping. |
| lib/legion/store/postgres.ex | Ensures decoded conversation state always includes execution (default nil). |
| lib/legion/store/payload.ex | Clarifies payload semantics and makes execution explicit in state type/docs. |
| lib/legion/store/migration/postgres.ex | Fixes migration docs wording (Legion vs Oban). |
| lib/legion/store.ex | Updates store contract docs to describe execution as nil or checkpoint map and references recovery. |
| lib/legion/recovery.ex | New startup worker that lists stores and calls Legion.recover/2 for eligible runs. |
| lib/legion/executor.ex | Adds optional execution param to run/5 to resume from checkpoints. |
| lib/legion/application.ex | Exposes children/0 and wires in the Recovery child with Application.fetch_env/2. |
| lib/legion/agent_server.ex | Adds start_monitor/2, stores/restores execution checkpoints, and runs resume/recover via handle_continue. |
| lib/legion.ex | Adds recover/2, refactors store lookup into store!/2, and updates resume/2 to use start modes. |
| CHANGELOG.md | Notes addition of recover/2 and :recovery startup configuration. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
running?/1 now also checks connected nodes through RPC
109472b to
2eec501
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated 2 comments.
Suppressed comments (3)
lib/legion/agent_server.ex:52
- The
:nameoption is no longer handled as a GenServer option; it will be treated as a config override and only produce an "Unknown Legion config keys" warning while being ignored. Since:nameis documented as removed, it’s better to fail fast with a clear error so callers don’t think they successfully registered a name.
defp start_args(agent_module, opts) do
{store, opts} = Keyword.pop(opts, :store)
{agent_id, opts} = Keyword.pop(opts, :agent_id)
store = store || Vault.get(:store) || Application.get_env(:legion, :store)
lib/legion/agent_server.ex:153
resumemode currently only continues execution when the last persisted message hasrole: "user". Step checkpoints persisted after aneval_resultwill have the last message as an assistant/user eval-result message, so the resumed agent will never continue and will remain stuck in:runningstate. Resume should continue when anexecutor_statecheckpoint is present, regardless of the last message role.
def handle_continue(%{start_mode: :resume, executor_state: executor_state}, state) do
if match?(%{role: "user"}, List.last(state.messages)) do
{_reply, state} = do_run(state, executor_state)
{:noreply, state}
else
lib/legion/recovery.ex:65
Task.async_stream/3results are fully discarded viaStream.run/1, so failed recoveries (e.g.{:error, :already_running}or unexpected exit reasons) are silent. This makes startup recovery failures hard to diagnose and can leave runs stuck as:runningwithout any visibility. Consider forcing the stream and logging non-:okoutcomes (and task exits).
|> Task.async_stream(
fn {store, %Payload{agent_id: agent_id}} -> Legion.recover(agent_id, store: store) end,
max_concurrency: concurrent_request_limit,
ordered: false,
timeout: :infinity
Adds recovery mechanics
When configured,
Applicationlaunches theRecoveryworker, which scans the provided stores for root agents (noparent_agent_id) which were interrupted mid run (status: :running). It then a Task which callsLegion.recover/2and drives them to completion before killing them.The recovery process is non-blocking when it comes to the application startup.