Runs, sessions & loops
Every burst of agent work is an episode with a start, an end, and a place in a tree. This chapter explains the run model, the two operating modes built on it, and the contract an unattended agent follows when nobody is watching.
How Ledgenter keeps track of which agent did what, even when many run at once.
Work is grouped into "runs" — a run is one work session by one agent. That's how you can always tell who did what, when, and as part of which effort, even with several agents (or a whole fleet) working at the same time.
It also handles agents that run on a schedule — a nightly cleanup agent, say — so each scheduled wake-up is its own clean, attributable session instead of one tangled, ever-growing log.
Three questions, three objects
Attribution in Ledgenter is deliberately split into three independent questions, each answered by its own object. Who acted? An actor — the stable principal resolved from an API key, never from a client-supplied parameter. During which episode? A run — the shift. Against which code? The run's repository context, detected from the working directory's git state when the process starts.
The split matters because the alternatives are both bad. If every subagent became a new
actor, the addressable roster would fill with one-shot ghosts — you could no longer put a
handoff in builder-1's inbox with confidence. So subagents never get child
actors: attribution by run node keeps the roster clean while the run tree keeps the
fine-grained history.
actor builder-1 (stable — resolved from its own API key) │ ├── series builder-1-loop ← LEDGENTER_RUN_GROUP (recurring jobs) │ ├── run #41 loop_tick · ended ─┐ seen_through │ ├── run #42 loop_tick · ended ◄┘ each tick's cursor seeds from the previous one │ └── run #43 loop_tick · active │ └── run #38 session · active repo ledgenter @ master · HEAD d69c2ec · dirty:false └── run #39 subagent · "research" ← run_fork
Runs form a tree — every run records its parent and its denormalized root — and group
into a series when they come from a recurring job. Each run also carries
seen_through, a per-run cursor that whoami advances on every call.
That cursor is what makes "what changed since I was last here" a per-episode answer: two
concurrent sessions of the same actor each get their own delta instead of trampling one
shared timestamp on the actor row.
The repository context is resolved once per process from the working directory's git
state: the remote (credentials stripped before anything crosses the wire), the branch, the
HEAD commit, a dirty flag — and only the worktree's basename, never the absolute
path. whoami.current_run shows all of it, and code-ref tools default their
repository, branch, and SHA from it, which is why recording a commit is often a one-argument
call (chapter 7).
The run lifecycle
A run is registered lazily: nothing is written until the agent's first
write, at which point core registers the ambient run exactly once. run_start is
idempotent on the run key — replaying it, or two concurrent callers racing it, converges on
the same row rather than minting duplicates. Registration is also best-effort: if it fails,
the agent's actual work proceeds anyway. And it is telemetry-grade by design — no write
scope is required, so even a read-only actor leaves attributable episodes (only the
repository auto-registration inside it is gated on write scope).
run_heartbeatproves liveness on a long run and tracks the moving HEAD — branch, SHA, and dirty state update as the work progresses. The reaper (below) is heartbeat-aware, so a busy run is never reaped just because it didn't chat.run_endcloses the episode with a status (succeeded,failed,cancelled) and acountssummary — what was claimed, completed, released, answered.run_forkregisters a child run (kindsubagent) for an in-process subagent, making the spawn visible in the tree. One honesty caveat, stated in the agent guide itself: over MCP, subsequent tool calls still attribute to the current run — no tool takes a run-key override yet. Actor and tenant attribution are always correct; run granularity is simply coarser. Use the fork for visibility; use the environment channel (next section) when a child must attribute fully.
Crashes are a designed-for case, not an exception path. A killed agent leaves an
active run and possibly a claimed task. Two independent mechanisms clean up:
the task's claim lease (default one hour) expires, making the task
claimable again with no reaper dependency; and the hourly abandoned-run
reaper marks runs idle for six hours as abandoned and releases the
tasks they were holding back to the pool — assignee cleared,
in_progress returned to todo. Nothing needs a supervisor to notice
the crash.
The environment channel
Out-of-process relationships — a spawned headless agent, a cron tick — can't share memory with their parent, so runs inherit through three environment variables that core resolves once at startup:
| Variable | Meaning | Effect |
|---|---|---|
LEDGENTER_RUN_ID | This process's run key | The process attaches to that run (matching the host's own telemetry). Unset, a fresh key is minted. |
LEDGENTER_PARENT_RUN_ID | The spawning run | The child mints its own run under the parent's tree — this is the path where out-of-process children attribute fully, every call on their own node. |
LEDGENTER_RUN_GROUP | A stable recurring-series key | The process is a recurring tick: a fresh run (kind
loop_tick) is minted under the series on every fire, and its
seen_through cursor is seeded from the previous tick's. |
The cursor-seeding chain is what gives recurring agents continuity without shared state:
a new run inherits its parent's cursor if it has one, else the previous tick of the same
series and actor, else the actor's last_seen_at. Each tick's first
whoami therefore answers exactly "what changed since my last tick". Series
identity includes the actor, not the key alone, so two unrelated jobs that happen to share a
group label cannot merge into one series.
// the only env difference between a session agent and a loop agent: "env": { "LEDGENTER_API_KEY": "<builder-1's own key>", // one actor per loop agent "LEDGENTER_API_BASE": "https://<project>.supabase.co", "LEDGENTER_RUN_GROUP": "builder-1-loop" // static — a fresh run is minted per tick }
Never pin LEDGENTER_RUN_ID across ticks. A statically
pinned run id alongside LEDGENTER_RUN_GROUP would collapse every tick onto one
run and destroy the per-tick cursor. Core detects the combination, warns on stderr, and
mints a fresh per-tick key anyway. The same guard applies to re-attachment: inheriting a
run id that points at an already-ended run re-mints rather than replaying onto a
dead episode.
Two more variables serve hosted agents with no filesystem:
LEDGENTER_REPO_URL / LEDGENTER_BRANCH / LEDGENTER_HEAD_SHA state
the repo context explicitly, and LEDGENTER_REPO_AUTODETECT=0 disables git probing
entirely.
Two modes, one office
Agents operate in one of two modes, and the mode is a pure function of the run's
kind: loop if and only if the kind is loop_tick or
cron_tick — which is exactly what LEDGENTER_RUN_GROUP produces — else
session. There is deliberately no separate mode toggle: a second environment
channel could contradict the real one, and an agent told "you're interactive" while running
as an unattended tick would behave wrongly in ways nobody is present to catch. If the run
row is missing, the mode is session — the fail-safe direction, because an
unregistered tick that under-claims wastes a tick, while one that over-claims grabs work it
may never finish.
whoami returns hints.mode, and the hint text is keyed
on it while the hint kind enum stays identical (hosts branch on the kind; prose is
for the agent). What actually differs:
| Session (a human is present) | Loop (an unattended tick) | |
|---|---|---|
| Posture | Follow the human; surface inbox items and notifications. | Drain the work, then exit. Never ask questions into the void. |
| Pool hint | Informational — "task_claim only if your operator wants you working the pool". | Imperative — "task_claim to take one". |
| Ready hint | Start the task: task_update to in_progress. |
Claim it: task_claim with the task id — the lease makes a crashed
tick recoverable. |
| Instructions | The standard always-on text. | Plus the unattended-mode addendum, appended only for recurring ticks — sessions never pay those tokens. |
| Closing | Don't call run_end; the host closes the run. |
run_end with counts, every tick — even an idle one. |
The session-mode pool softening is a deliberate fix, not an accident: early session agents aggressively auto-claimed pool work their operators hadn't asked for. The hint now tells them the pool exists without telling them to raid it.
The drain contract
A loop tick follows one algorithm, taught verbatim by
guide('sessions-and-loops') and the unattended addendum. Inbox first — another
actor is waiting on those answers — then at most one task, end to end:
whoami // hints.mode:'loop'; since_last_seen = since my last tick handoff_claim → handoff_respond → inbox ack:[ids] // inbox first; claim gives exactly-once on multi-recipient task_claim // AT MOST ONE task; size lease_seconds ≈ 2× the expected tick // finished → task_update status:done (verification gates apply) + task_code_ref the commit // can't finish → comment_add your progress, then task_release with a note decision_log / knowledge_write // anything durable run_end counts:{ tasks_claimed, tasks_completed, tasks_released, handoffs_answered }
The contract's edges are where the design shows. Escalate, never
stall: blocked, or failing the same task repeatedly, means
task_release with a note plus handoff_create to a human — nobody
answers a question asked by an unattended tick. Never exit holding a silent
claim: lease expiry is the safety net, but releasing is immediate. Long
task? run_heartbeat keeps the run alive and unreapable.
Retries and crashes are safe by construction. Each tick is a fresh run, and
auto-derived idempotency keys fold in the actor and the run — so a re-fired tick
cannot phantom-replay the previous one's writes. (For deliberate cross-tick dedupe, like
"ensure today's report task exists", pass an explicit date-stamped
idempotency_key.) A killed tick's claim returns to the pool at lease expiry and
the reaper ends its run; the one behavioral rule is that after re-claiming a task a crashed
tick held, call task_get first and read the existing comments and code refs
before redoing work.
Backoff is self-pacing for self-scheduled loops: when the idle hint repeats and the totals show inbox 0 / ready 0 / pool 0, double the interval (2m → 4m → … capped at 30m) and reset on any work. Fixed schedulers keep their cadence — the poll preflight below makes idle ticks approximately free.
The /loop caveat. Recurring ticks inside one Claude
Code session share one MCP process — which means one run. since_last_seen
still advances per whoami call, but run-tree granularity is coarse, and
identical-content writes across ticks can idempotency-collapse because the keys fold the
same run. Pass explicit keys for deliberately repeated writes, or prefer a scheduled
headless tick, which gets a fresh run per fire via LEDGENTER_RUN_GROUP.
Waking up: the poll preflight
An operator scheduling ticks every fifteen minutes mostly schedules idle ticks,
and spawning a full agent to discover there is nothing to do costs real money. The answer is
ledgenter agent poll — and it is deliberately not whoami.
whoami advances the run's seen_through cursor in the same
transaction, and any domain write would lazily register a run; a five-minute poll must not
burn the next tick's since-delta or mint junk run rows. Poll is therefore side-effect-free
by construction: RLS-scoped HEAD counts only, no run registration, no cursor movement, zero
trace.
# poll → spawn only when there is work; exit 3 = idle short-circuits the chain */15 * * * * LEDGENTER_API_KEY=$(cat ~/.ledgenter/keys/builder-1.key) \ ledgenter agent poll --quiet --soft-fail && \ claude -p "$(cat ~/agents/builder-tick.md)" --mcp-config ~/.ledgenter/mcp/builder-1.mcp.json # or let poll run the spawn itself — its exit code propagates: ledgenter agent poll --exec "claude -p ..."
The counts mirror whoami's definitions — inbox (open or claimed handoffs
addressed to you), your ready tasks, the unassigned ready pool, and unread notifications
(counted toward "work available" only with --include-notifications), scopable
by --project and --label. Exit 0 means work
exists; exit 3 means idle — a code deliberately outside the CLI's normal
sysexits mapping so a cron chain can distinguish "nothing to do" from success and from
failure. Transient failures under --soft-fail skip the tick quietly, but a
revoked or expired key fails loudly (exit 77) even then: a loop agent that silently looks
idle forever is the failure mode this design refuses.
What's deferred. Polling is the first rung of a planned wake
ladder. The later rungs — a Realtime watch channel for server-push wake-ups
and per-tenant outbound webhooks — are designed but not yet built. Polling shipped first
because it is debuggable, costs nothing when idle, and needs no new
infrastructure. The full operator story (scheduler registration, the tick wrapper, the
failure table) lives in chapter 11.