ADR 0023 Execution fence on the context - agbossjs/agboss GitHub Wiki
ADR-0023 — The execution fence on the context
- Status: Accepted (#471)
- Issue: #471
Context
A step runs at least once (#265): an instance frozen past its 30 s lease (a GC pause, SIGSTOP, a
VM migration) wakes and goes on with the step it was in, while the instance that took the tree over
runs the same step again. The framework fences only its own checkpoint writes (#335). A step's
write to another system lands whenever it is sent, so an application that needed ordered or
exactly-once writes read AGBoss's lease table and kept its own token — internal schema,
implementation-specific, and wrong for ChildPlacement.AnyInstance children, which hold leases of
their own.
Decision
- Everything on
executionContext.fence(ExecutionFence): the lease the current step runs under —scopeKey,tenantId,leaseExecutionId,token,confirmedUntil,assertHeld(),confirmNow()andfenced(observedToken). No new injectable: a step already has itsExecutionContext, and the fence is a fact of its frame, read live (a fence kept past its step refuses). One I/O method is accepted on the otherwise data-only context:confirmNow(), a strict renewal before a call that cannot check tokens, which also runs the #360 cancel checks. - The token is a number, per lease key:
leaseEpochof(tenantId, leaseExecutionId)— 1 for its first holder, + 1 with every take. Written intoExecutionLeaseRepository's contract, so a distributed store (#469) counts per key, never by its own revision; nobigint. - The scope is a string with its parts: each part
encodeURIComponent-encoded and joined by one:— no cross-tenant collision, decodable, unchanged for plain and UUID ids, no\u0000(rejected by PostgreSQLTEXT). The root's lease covers the root, itsParentInstancechildren andThisWorkflowlevels; anAnyInstancechild fences with its own. Ancestors' fences and frames without a held lease refuse withExecutionContextUnavailableError. - A refusal is a hand-over error:
ExecutionFencedError extends ExecutionLeaseLostError(execution.lease-lost.fenced, following the rule that a subclass's code extends its parent's), found anywhere in a cause chain or anAggregateErrorand checked before the signal's reason at every place the runtime decides about a failure — the error itself is rethrown, and the holder of its token is stopped with it. - The remote-children wait yields the lease only while no other step of the tree runs: a
sibling
ParentInstancebranch's step used to run and checkpoint with the root's lease released. - Testing: a
leasesfixture (stall/resumearound the lease contract and the instance's lease timers) and a held lease in every unit runner (fenceoption).
Consequences
- Exactly-once becomes possible where the external system checks the token and the idempotency key in one transaction; the framework never claims it on its own (documented with a reference SQL).
- A high-water mark must be keyed by
scopeKey: keyed by a shared aggregate it refuses healthyAnyInstancechildren with independent epochs. - A step whose lease's own expiry fires before its fenced write settles still ends its call with
ExecutionLeaseLostError(LangGraph rejects the pass on the abort before the step's body returns); the write is refused either way. - A withdrawal made after the tree ended runs without a fence (its frame is rebuilt from the checkpoint); a withdrawal made inside a pass has its step's.