Repository navigation
Several instances
An application may run as several instances (processes, machines) over shared stores: any instance continues an execution another one paused, and two instances never move one execution tree at the same time. This page says what is shared, how a tree is moved by one instance at a time (its lease), and how to test it (#265).
Every store is an infrastructure contract with a built-in in-memory
default, listed only in @Application({ providers }) and found by inheritance. In memory nothing
is shared between instances and nothing survives a restart, so with more than one instance list a
shared implementation of each:
| Contract | Shared because |
|---|---|
BaseCheckpointSaver |
a pause made on one instance is resumed on another |
PausedExecutionRepository |
the same; a pause goes Pending → Resolved once, whichever instance answers it |
ExecutionRecordRepository |
threads, owners and records are the application's, not an instance's |
CostLedgerRepository |
budgets count the spend of every instance |
ExecutionLeaseRepository |
one holder per execution tree (below) |
ExecutionTreeRepository |
the shared state of one request, read by its executions on any instance |
RateLimitRepository |
a model provider's limit, its 429 pause and its circuit breaker hold on every instance (#266) |
Each contract has a ready SQLite implementation (#265 PR 3), or a project lists its own
(extends the contract, @Injectable({ deps }), below).
| Contract | SQLite implementation | Tables |
|---|---|---|
BaseCheckpointSaver |
SqliteCheckpointSaver |
checkpoints, checkpoint_writes
|
PausedExecutionRepository |
SqlitePausedExecutionRepository |
paused_executions |
ExecutionRecordRepository |
SqliteExecutionRecordRepository |
the Tern tables (threads, terns, …) |
CostLedgerRepository |
SqliteCostLedgerRepository |
cost_ledger, cost_ledger_max_spend
|
ExecutionLeaseRepository |
SqliteExecutionLeaseRepository |
execution_leases |
ExecutionTreeRepository |
SqliteExecutionTreeRepository |
execution_tree_keys |
RateLimitRepository |
SqliteRateLimitRepository |
rate_limit_slots, rate_limit_window_entries, rate_limit_pauses
|
Every row carries the tenant (#261): the threads and the records tenant_id, paused_executions
is keyed by (tenant_id, thread_id), a checkpoint and its writes are tagged with the run's tenant,
the leases and tree keys are keyed by it; a file written before tenants is migrated on first use,
its rows in the tenant "default".
Each one is enabled only by listing it in @Application({ providers }) — list one, some or all; a
contract not listed keeps its in-memory default. They are built on node:sqlite (no native
driver to install; SqliteCheckpointSaver is our own BaseCheckpointSaver, serializing with
LangGraph's serde) and share one file: the environment contract's sqlitePath, required only
when a SQLite implementation is listed — a contract without it fails assembly with
[environment.missing-field] SqliteExecutionLeaseRepository needs sqlitePath: string in AppEnvironment (Environments), an empty value fails the start naming it.
// environments/app.environment.ts — the contract
export class AppEnvironment {
sqlitePath!: string; // required because SQLite implementations are listed
}
// environments/prod.environment.ts — one file per machine; tests: sqlitePath: ":memory:"
export const environment: AppEnvironment = {
sqlitePath: env("AGBOSS_SQLITE_PATH"),
};
// job-scout.application.ts: @Application({ …, environment: AppEnvironment, providers: sqliteStores })
export const sqliteStores = [
SqliteExecutionRecordRepository,
SqliteCostLedgerRepository,
SqlitePausedExecutionRepository,
SqliteCheckpointSaver,
SqliteExecutionLeaseRepository,
SqliteExecutionTreeRepository,
SqliteRateLimitRepository,
];-
One connection per application instance, shared by its SQLite stores and opened when the
first one is created; two instances (two processes, or
testWith(App, { instances: 2 })) open two connections to one file. The file is in WAL mode (readers never block the writer) with abusy_timeoutof 5 s (a writer waits for the other's lock, then fails — it never hangs); the connection is closed inonDestroywhen the instance stops.SqliteConnectionis the DI token of that connection (a subclass names it in itsdeps, below); the container creates it — project code never constructs it. -
Atomic operations — a lease
acquire, a slotacquire,saveIfAbsent, a pausePending → Resolved, a ledger counter — run in oneBEGIN IMMEDIATEtransaction, so of two instances exactly one wins;renew/releaseand the records' compare-and-set write are one statement each. -
Time comes from the application's
Clock(#262, Testing → The clock), never from SQLite (expiresAt, the ledger's UTC day are parameters), soclock.advance(…)moves SQLite leases in tests like in-memory ones. -
node:sqliteis synchronous: a transaction is one group of statements with noawait, and a busy wait blocks that instance's event loop for at most the busy timeout. - With them, what one process wrote is there for the next:
agboss run/chat --thread <id>continue a thread after a restart,agboss eval/replay/goldenread the records of earlier runs (a workflow run on its own uses the infrastructure its application lists), a pause is resumed after a restart, and another instance on the file recovers a run whose process died (Recovery). job-scout lists all seven (sqlitePathin dev:.agboss/job-scout.sqlite, orAGBOSS_SQLITE_PATH).
A SQLite implementation is a class like any other: a project extends it, overrides what it
changes and lists the subclass instead of it (both listed → [di.ambiguous-implementation]).
The subclass takes the base class's dependencies first — every one a public token (#265 S70):
SqliteConnection, the application's Clock, for the leases, the rate-limit slots and the records
the ApplicationContext (its instanceId is the holder of what this instance takes), and for the
records the application's IdGenerator (#313: a new thread's id):
| Implementation | Its deps
|
|---|---|
SqliteCheckpointSaver, SqliteExecutionTreeRepository, SqliteCostLedgerRepository (#261 PR 5: each counter's period is named by the runtime from the Clock) |
[SqliteConnection] |
SqlitePausedExecutionRepository |
[SqliteConnection, Clock] |
SqliteExecutionLeaseRepository, SqliteRateLimitRepository
|
[SqliteConnection, Clock, ApplicationContext] |
SqliteExecutionRecordRepository |
[SqliteConnection, Clock, ApplicationContext, IdGenerator] |
import {
ApplicationContext,
Clock,
Injectable,
SqliteConnection,
SqliteExecutionLeaseRepository,
} from "agboss";
import type { Milliseconds } from "agboss/units";
/** The SQLite leases, each one taken written to the audit log as well. */
@Injectable({ deps: [SqliteConnection, Clock, ApplicationContext, LeaseAuditService] })
export class AuditedSqliteExecutionLeaseRepository extends SqliteExecutionLeaseRepository {
constructor(
connection: SqliteConnection,
clock: Clock,
applicationContext: ApplicationContext,
private readonly leaseAuditService: LeaseAuditService,
) {
super(connection, clock, applicationContext);
}
override async acquire(
tenantId: string,
rootExecutionId: string,
lease: Milliseconds,
): Promise<ExecutionLease | undefined> {
const taken = await super.acquire(tenantId, rootExecutionId, lease);
if (taken !== undefined) this.leaseAuditService.taken(taken);
return taken;
}
}The subclass still needs sqlitePath ([environment.missing-field] names it) and shares the
instance's one connection. The records the same way (#313) — a new thread's id comes from the
IdGenerator it passes on (App → Infrastructure):
import {
ApplicationContext,
Clock,
IdGenerator,
Injectable,
SqliteConnection,
SqliteExecutionRecordRepository,
type NewThread,
} from "agboss";
/** The SQLite records, each new thread written to the audit log as well. */
@Injectable({
deps: [SqliteConnection, Clock, ApplicationContext, IdGenerator, ThreadAuditService],
})
export class AuditedSqliteExecutionRecordRepository extends SqliteExecutionRecordRepository {
constructor(
connection: SqliteConnection,
clock: Clock,
applicationContext: ApplicationContext,
idGenerator: IdGenerator,
private readonly threadAuditService: ThreadAuditService,
) {
super(connection, clock, applicationContext, idGenerator);
}
override async createThread(thread: NewThread): Promise<string> {
const threadId = await super.createThread(thread);
this.threadAuditService.created(threadId);
return threadId;
}
}One lease per execution tree, keyed by (tenantId, rootExecutionId) — the execution's tenant
(#261, "default" when it names none); the holder's holderInstanceId is its instance's
ApplicationContext.instanceId. Whoever moves the tree holds it: app.execute from its first step,
app.resume once it has recorded its decision. The holder:
- renews the lease in the background every 10 seconds (a third of its fixed length,
seconds(30)— not a setting) and right before every checkpoint write; - releases it when its pass ends — the tree paused, finished or failed. A paused or finished tree holds no lease;
- when a renewal answers
false(the lease expired — the instance was frozen or cut off for longer than 30 seconds — and another instance took it,leaseEpoch + 1), stops: its work is aborted through its signal and its call rejects withExecutionLeaseLostError(execution.lease-lost). That is not a failure of the execution: it reaches neither.onError,onExecutionErrornor the execution record — the new holder owns the execution and continues it.
Two resumes at once. A resume has two phases: it first records its decision on its pause (the
pause goes Pending → Resolved atomically — a second resume of the same pause, a double click,
gets NotPausedError), then takes the tree's lease. While another call or instance holds it, the
resume waits; the holder continues with one Command({ resume }) that maps LangGraph's interrupt
id of every answered pause of the tree, and before it lets the tree rest paused takes the decisions
resolved meanwhile (the drain), so the waiting call then returns the tree's current
ExecutionOutput, its decision taken. Two pauses of parallel branches answered at once on two
instances are both taken, each step once (Pauses). If the holder's
lease expires while a call waits, the waiting call takes it and goes on.
Documented limit. The renewal before a checkpoint write and the write are two round trips to the
stores, not one atomic operation (BaseCheckpointSaver is taken as is, so ecosystem savers plug
in): the race window is one round trip.
An execution whose instance died mid-step is continued by another instance, automatically (#265
S30). The record of a root is ExecutionRecordStatus.Running while its tree moves (a resume makes
it running again once it has recorded its decision). Every instance, at its start and then every
30 seconds (the lease length, through the framework's clock):
- lists the
runningrecords (ExecutionRecordRepository.findByStatus(ExecutionRecordStatus.Running, limit, after), page by page); - tries each one's lease: a live holder renews it, so only a tree whose holder died (its lease
expired) or stopped (released it) is taken, with
leaseEpoch + 1; of two instances sweeping at once one gets it; - continues the tree from its last checkpoint, taking a decision a resume recorded meanwhile
first (a
Resolvedpause). Every step that finished keeps its writes — every step is checkpointed before the next one starts — so only the interrupted step runs again; a side-effecting tool keeps its effect once throughexecutionContext.step.idempotencyKey; - checks compatibility as
app.resumedoes: a root started under another config hash is not continued unless the workflow'sonIncompatibleResumesays"resume"— it is left to an instance of a compatible version (a rolling deploy) and logged once.
Each recovery is logged (execution.recovered, a warning in the application's
log with its executionId and tenantId; a root left to a
compatible instance is recovery.incompatible, a failed one recovery.failed) and traced, and
observers get onExecutionResume with reason: ResumeReason.Recovered (decision when it took a
recorded one), then onExecutionEnd / onExecutionPause / onExecutionError as for any pass.
@Injectable()
export class RecoveryCounter implements OnExecutionResume {
onExecutionResume(event: ExecutionResumeEvent): void {
if (event.reason === ResumeReason.Recovered) {
// an instance died or stopped while this run moved: count it, alert
}
}
}The same sweep keeps the pause records small (#267 S107): at each look it removes, across
tenants, the resolved pause records (Taken, Cancelled) resolved more than
@Application({ pauseRecordRetention }) ago — default hours(24) —
through PausedExecutionRepository.deleteResolvedBefore(cutoff), cutoff = the Clock's now minus
the retention. Every instance may run it; it is idempotent. Within the window a late answer to a
taken or withdrawn pause gets NotPausedError, after it UnknownPauseError; a Pending pause is
never removed (Pauses → a pause's life). A project's own
PausedExecutionRepository implements deleteResolvedBefore too — one statement over the records'
status and resolvedAt (SqlitePausedExecutionRepository: an index on (status, resolved_at)). Who
answered each pause is not in these records: it is written into the execution records when the
pause is resolved (ExecutionRecordRepository.savePauseAnswer), so the sweep never changes
result.record.pauses.
A free-text reply (app.resume({ pauseId, reply }), #267 PR 3) is read on the instance that got
the call, before the pause is resolved and before the tree's lease is taken: the interpreter's
model call holds no lease, and its spend reaches the tree's budgets through the shared ledger at
once, whichever instance then moves the tree.
A graceful stop (app.close(), SIGTERM — App → app.close()) first stops
the instance's own sweep, waits for its running executions up to the shutdown timeout (default
seconds(25)), then interrupts what is left: each releases its lease at once and stays running,
so another instance takes it at its next sweep, without waiting for the lease to expire.
ExecutionTreeRepository keeps keys under the request's root execution: visible to the whole tree
of executions at any depth and on any instance, invisible to other requests, removed when the tree
finishes, fails or is cancelled (a pause keeps them). saveIfAbsent is atomic — of two branches
claiming one key exactly one gets true. Keys come from identifiers in DTOs, never from text:
import { ExecutionTreeRepository, Tool, type ToolHandler } from "agboss";
@Tool({
name: "claim_subtopic",
description: "Claims a subtopic once per request",
input: SubtopicDto,
output: SubtopicClaimDto,
deps: [ExecutionTreeRepository],
})
export class ClaimSubtopicTool implements ToolHandler<SubtopicDto, SubtopicClaimDto> {
constructor(private readonly executionTreeRepository: ExecutionTreeRepository) {}
async run(input: SubtopicDto): Promise<SubtopicClaimDto> {
const claimed: boolean = await this.executionTreeRepository.saveIfAbsent(
`subtopic:${input.subtopicId}`, // by identifier, never by normalized text
true,
);
return { researcher: claimed ? "this branch" : "another branch" };
}
}A project's implementation extends the contract and is listed in @Application({ providers })
(<Qualifier><Contract>, e.g. RedisExecutionLeaseRepository). Its operations are atomic, and every
time value comes from the framework (lease is a duration, expiresAt a Date; the current time
from the application's Clock, deps: [Clock]), never from the store's own clock:
import { ExecutionLeaseRepository, Injectable, type ExecutionLease } from "agboss";
import type { Milliseconds } from "agboss/units";
@Injectable({ deps: [LeaseTableService] })
export class TableExecutionLeaseRepository extends ExecutionLeaseRepository {
constructor(private readonly leaseTableService: LeaseTableService) {
super();
}
override acquire(
tenantId: string,
rootExecutionId: string,
lease: Milliseconds,
): Promise<ExecutionLease | undefined> {
return this.leaseTableService.insertIfFree(tenantId, rootExecutionId, lease);
}
override renew(executionLease: ExecutionLease, lease: Milliseconds): Promise<boolean> {
return this.leaseTableService.extendIfHeld(executionLease, lease);
}
override release(executionLease: ExecutionLease): Promise<void> {
return this.leaseTableService.deleteIfHeld(executionLease);
}
override ping(): Promise<void> {
return this.leaseTableService.selectOne(); // one round trip: what readiness calls (#300)
}
}Every store contract has ping() (#300): one fast round trip that resolves when the store answers
and rejects with why when it does not — app.checkReadiness() calls it under a seconds(2)
timeout, and a rejection or a timeout is that store's Unhealthy (Health checks).
testWith(App, { instances: 2 }) runs two application instances in one test — apps[0] (= app)
and apps[1], each with its own container and instance id — over one set of stores and one clock
(clock, shared). With the in-memory defaults the instances share their rows, so the framework's
own implementations are tested across instances; nothing is substituted, so an application that
lists its own implementations is tested on those (against Testcontainers, or with its injected
client replaced by mockOf(…)).
const test = testWith(LeaseApplication, { instances: 2 });
test("a pause made on one instance is resumed on the other", async ({ app, apps, mockLlm }) => {
mockLlm(ClerkAgent).thenReturn(callTool(ChargeTool, { amount: "10" }), replyWith("Charged."));
const paused = await app.execute(OrderWorkflowStart, { text: "charge" });
const pauseId = paused.pending[0]?.pauseId ?? ""; // a ThreadOwner pause lists its id
const resumed = await apps[1].resume({ pauseId, decision: { approved: true } });
expect(resumed.output).toEqual({ text: "Charged." });
});
test("a holder frozen past the lease loses it", async ({ clock }) => {
// … one instance moves the tree, the other waits for it …
await clock.advance(seconds(31)); // the time jumps: the waiting instance takes the lease first
});Recovery is tested the same way: kill one instance (a lease store of the test's own whose
renew answers false and whose acquire gives nothing for that instance — a crashed process, as
the store sees it), await clock.advance(seconds(31)), and the other instance's sweep continues the
tree; or await app.close({ timeout }) with an execution still running, and the other instance
continues it at its next sweep.
clock is the test's VirtualClock — one for all the instances of the test, the Clock every one
of them reads (Testing → The clock). await clock.advance(d) lets the work
already started reach its next wait, moves the time, then wakes every wait that is due in time
order (lease renewals, a resume waiting for a lease, the recovery sweep) — a jump longer than the
lease is a holder frozen for that long.
Each instance answers its orchestrator's probes through app.checkLiveness() and
app.checkReadiness() — the project's server maps them to its endpoints (Health checks).
Liveness does no I/O: an instance that is stopping (app.close(), SIGTERM) is Unhealthy
(stopping) so the orchestrator stops sending it traffic while it drains, and a recovery loop whose
last sweep ended more than three sweep intervals (90 s) ago is Unhealthy (stuck: …) — a store it
waits on that never answers. Readiness adds every store's ping() and the circuit breakers of the
model providers as RateLimitRepository keeps them for every instance: an open breaker makes every
instance Degraded at once (it still serves; calls go to the fallback or fail fast), never
Unhealthy.
- With the in-memory defaults nothing is shared between instances and nothing survives a restart: run one instance, or list shared stores.
- SQLite on one file serves one machine: its locks are local, so instances on several machines need a networked store (your own implementation over Postgres, Redis, …). A file on a network share is not supported.
-
SQLite in memory is not SQLite on disk:
sqlitePath: ":memory:"gives each connection its own database — instances do not share it, a restart (recoverApp()) starts empty, and there are no locks between writers. A test of several instances or of a restart on SQLite uses a temporary file. - With the in-memory defaults nothing survives a restart, so there is nothing to recover: recovery works with persistent stores (records, checkpoints, paused executions, leases).
- A stop releases its leases at once, but the other instances notice at their next sweep (within 30 s); a crashed instance's trees wait for their lease to expire first (up to 30 s more).
- Fairness between tenants waiting for a model provider's slots is per instance (#266): the
tenant-fair queue is in-process, and between instances a slot goes to whoever takes its lease
first. So is the AIMD slowdown after a 429 — the pause itself, the hard caps (
maxConcurrentCalls,perMinute) and an open circuit breaker are shared throughRateLimitRepository.
RateLimitRepository holds three kinds of state, each atomic across instances:
-
slots —
acquire(key, maxSlots, lease)/renew/release: a provider'smaxConcurrentCalls(<provider>:slots), a lease of a minute renewed while a call runs; -
windows —
addToWindow(key, amount, window, limit?)(the new total,undefinedwhen it would passlimit;amount0 reads it) andclearWindow(key): the per-minute calls and tokens, and a circuit breaker's failures; -
pauses —
pauseUntil(key, until)(a later pause is kept) andpausedUntil(key): the pause after a 429 (<provider>:cooldown) and an open breaker (<provider>:circuit-open).
SqliteRateLimitRepository keeps them in rate_limit_slots, rate_limit_window_entries and
rate_limit_pauses; a file an older version wrote gets the two new tables when it is opened. Your
own implementation (Redis, …) extends RateLimitRepository and implements all eight methods (ping() included, #300); the
shared contract suite of the framework (tests/infrastructure/rate-limit.contract.test.ts) is the
behaviour to match.
AGBoss
- Home
- Delivery Pipeline
- Architecture
- Configuration
- Environments
- Workflow — the paths
- Path scope
- Workflow as a step
- Parallel — quorums and arbiters
- Routing patterns
- Rollback
- Routers
- Guards
- CLI — agboss
- App — @Application and createApp
- Several instances
- Health checks
- Context and Authorization
- Testing
- The test split
- Suites and Comparisons
- Components
- Standard DTOs
- Examples: job-scout, flow-patterns
- Agents — the loop
- Model providers
- Tools
- Knowledge bases (RAG)
- FinOps
- Tracing
- Observability
- Terns and evaluation
- Profiles and comparisons
- Pauses — pending, resume by id
- Pause seam
- Decisions (ADR)