Chapter 06

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.

In plain language

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_heartbeat proves 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_end closes the episode with a status (succeeded, failed, cancelled) and a counts summary — what was claimed, completed, released, answered.
  • run_fork registers a child run (kind subagent) 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:

VariableMeaningEffect
LEDGENTER_RUN_IDThis 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_IDThe 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_GROUPA 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.

loop agent MCP config
// 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:

one tick
whoami                                // hints.mode:'loop'; since_last_seen = since my last tick
handoff_claimhandoff_respondinbox 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.

the cron rung
# 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.

[i]

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.