How Lorenz works
This page is a concept-first walkthrough for anyone deciding whether to run or evaluate Lorenz. It
covers the end-to-end loop, what a WORKFLOW.md is, how agents run inside per-issue workspaces, and
how restart recovery works. For the mechanics behind each step, follow the links to
agent-orchestrator.md and dispatch.md.
The core loop
Lorenz turns tracker issues into agent runs. It watches a tracker (Linear, Jira, a local board, Slack) for issues in active states, and for each eligible issue it prepares a workspace, renders a prompt, and runs a coding agent inside that workspace. It keeps doing this on a fixed cadence until the issue reaches a terminal state.
There is one control-plane process. It holds a single authoritative in-memory view of everything in flight (which issues are running, reserved, retrying, or blocked) and feeds that same view to every dashboard. Each issue gets its own filesystem workspace. Each agent run is one ACP session driving either Codex or Claude.
The loop runs at polling.interval_ms (default 30000). On each tick the runtime reloads the
workflow, reconciles what is already in flight against the tracker, fetches candidate issues,
computes which are eligible, and dispatches them. Each dispatched issue runs as its own detached
promise, so a slow agent run never stalls the poll loop. The next section walks one tick in order.
One poll tick
A tick, driven by LorenzRuntime.pollOnce, reloads the workflow, validates dispatch config,
reconciles what is already in flight against the tracker, fetches candidate issues, filters to the
eligible set, then dispatches. The exact ordered steps - including the once-only terminal-workspace
cleanup and the stalled-run pass - are spelled out in agent-orchestrator.md.
Eligibility is a pure decision. An issue dispatches when its state is active and not terminal, it is
routed to this worker, it has no open blockers (checked only for unstarted issues), and a
concurrency cap does not block it. Caps apply in order: the global agent.max_concurrent_agents
(default 10), then a per-state cap from status_overrides, then worker-host capacity. The dispatch
order is total and deterministic: priority ascending, then createdAt ascending, then identifier by
locale compare. The full decision tree lives in dispatch.md.
WORKFLOW.md is config plus prompt
One file defines both how Lorenz is configured and what it tells the agent. WORKFLOW.md has YAML
front matter (the orchestrator config: tracker, agents, workspace, polling, workers) and a Markdown
body (the agent session prompt). The body is a Liquid template rendered with the issue's fields, so
the prompt the agent receives is specific to the issue it is working on.
Editing WORKFLOW.md while Lorenz runs reloads the config on the next tick. No restart. The reload
is transactional: side effects that can fail run first, and the live config swaps in only if they all
succeed, so a typo never takes down a running deployment. See
workflows.md and the
workflow + prompt reference for the full schema.
Per-issue workspaces
Every issue runs in its own directory under workspace.root (default
<tmpdir>/lorenz_workspaces), named after a sanitized form of the issue identifier. The workspace is
created on first dispatch and removed when the issue goes terminal.
Four lifecycle hooks (after_create, before_run, after_run, before_remove) run around the
work, each via bash -lc; see workspace.md for what each one does and how failures
are handled. Workspaces are containment-checked against the realpath of workspace.root, so a
symlink cannot point an agent's working directory outside the root. Skills are overlaid into
.lorenz/skills/ with a .gitignore of * so they are never committed. Workspaces can live locally
or over SSH on a worker host.
Agents run over ACP
A run is one agent session. Lorenz drives an external bridge subprocess (codex-acp or
claude-agent-acp) over the Agent Client Protocol. The only built-in executor is acp, selected per
agent kind via agents.<kind>.executor.
The agent runner builds the workspace, runs before_run, opens a session, then runs up to
agent.max_turns autonomous turns (default 20). Turn 0 sends the rendered prompt; later autonomous
turns send a continuation prompt. Human steering accepted by the active session is drained in
addition, with the same per-run limit applied separately to steering turns. Two timers guard each
turn: a hard turn timeout (turn_timeout_ms, default 3600000) and a stall timeout
(stall_timeout_ms, default 300000) reset on every agent event. Either firing rejects the active
turn and terminates its ACP session.
Codex and Claude differ only in how Lorenz feeds them provider config and reads token usage; both ride the same ACP path. The bridge contract, including how Lorenz normalizes each bridge's counts into session-cumulative token totals, is in agents/acp-bridges.md, with per-agent specifics in agents/codex.md and agents/claude.md.
Reconciliation keeps the world honest
Lorenz never trusts its in-memory state as the source of truth about issues. Every tick it re-fetches the issues it thinks are in flight and reconciles them against the tracker: a still-active, routed, unblocked issue keeps its run, while terminal, unrouted, blocked, or inactive issues get stopped and cleaned up. A failed re-fetch keeps everything running so a transient tracker outage does not kill live runs, and a separate stall pass finishes runs that have gone quiet. The per-outcome classifications and the stall machinery live in agent-orchestrator.md.
Restart Recovery
With the default in-memory claim store, OrchestratorState (running, reserved, claimed, retrying,
completed, usage totals, rate limits, blocked dispatches) is rebuilt from scratch on every boot.
Recovery after a restart is two-sided: the tracker says what still needs doing, so eligible issues
re-dispatch, and the filesystem is reconciled against the tracker so terminal workspaces are swept.
With an explicit durable claim store, retry state and claim ownership hydrate from the store before
the next poll. The exact restart-recovery passes are in agent-orchestrator.md.
The default in-memory mode persists no scheduling state beyond the JSON event log, which is on by default at
logging.log_file and writes only when that key is set. This is by design; the tracker plus the
filesystem are enough to recover a clean view of the world.
Where it all surfaces
Every dashboard reads the same object. The runtime assembles a single RuntimeSnapshot and
broadcasts it to all subscribers - the Ink terminal dashboard (TUI), the web dashboard, and the HTTP
API. One snapshot, many views. See observability.md for what the snapshot carries
(including the bounded recent-events and run-history rings) and reference/events.md
for the event vocabulary.
See also
- getting-started.md - install Lorenz and run your first workflow
- agent-orchestrator.md - the poll loop, state machine, and reconciliation in depth
- dispatch.md - eligibility, routing, caps, retries, and the two-phase pool path
- workspace.md - workspace layout, hooks, skills, and containment
- architecture.md - the package layout and extension points