Repository navigation
Parallel
A parallel launch (#273, Workflow → Parallel launches) waits for every
branch with .join(). Often not every answer is needed: two scores out of three are enough, the
first five checked companies are enough, or one of several known combinations of reviewers decides
the case. A quorum (#279) says how many answers are enough, how long to wait, which answers
count and — with groups — which combination wins and where the execution goes next.
Status. #279 PR 1 (the grammar,
@Arbiter/@OnAnswer, the analyser, the generated types) and PR 2 (quorums at run time: counting, cancelling the rest, the deadline on theClock,QuorumNotReachedError,ArbiterError, traces andonQuorumDecided,agboss generate arbiter). A runnable example:examples/flow-patterns/src/panel.
// arbiters/in-range.arbiter.ts — by code: one @OnAnswer(Agent) method per agent of the launch
@Arbiter({ name: "in-range-arbiter", decidedBy: DecidedBy.Code })
export class InRangeArbiter {
@OnAnswer(ScoreAAgent)
fromScoreA(answer: ScoreDto): boolean {
return this.isInRange(answer);
}
@OnAnswer(ScoreBAgent)
fromScoreB(answer: ScoreDto): boolean {
return this.isInRange(answer);
}
@OnAnswer(ScoreCAgent)
fromScoreC(answer: ScoreDto): boolean {
return this.isInRange(answer);
}
private isInRange(answer: ScoreDto): boolean {
return answer.score >= 1 && answer.score <= 10;
}
}
// arbiters/valid-score.arbiter.ts — by model: a criterion for every agent of the launch
@Arbiter({
name: "valid-score-arbiter",
decidedBy: DecidedBy.Model,
model: "typesafe/jev-1.13",
prompt: "Count an answer only if it meets the criterion of the agent that gave it.",
criteria: [
{ agent: ScoreAAgent, prompt: "Gives a 1–10 score for technical skills, with a reason" },
{ agent: ScoreBAgent, prompt: "Names at least two concrete risks" },
{ agent: ScoreCAgent, prompt: "Ends with a clear hire / no hire" },
],
})
export class ValidScoreArbiter {}
export const quorumPaths = [
// any two of three: the counted answers only, the third cancelled
start(CandidateWorkflowStart)
.returns(CandidateDto)
.toParallelAgents(ScoreAAgent, ScoreBAgent, ScoreCAgent)
.acceptedBy(anyOf(InRangeArbiter, ValidScoreArbiter)) // the model only when the code rejects
.joinQuorum({ min: 2, max: 3, deadline: seconds(30) })
.returns(listOf(ScoreDto))
.toAgent(ScoreSummaryAgent)
.returns(ScoreReviewDto)
.toFinish(ScoreReviewWorkflowFinish),
// groups: the first complete one (by priority) continues its own path
start(DealWorkflowStart)
.returns(CandidateDto)
.toParallelAgents(LegalAgent, CriticAgent, CriticAgent, FastScreenAgent)
.joinQuorumGroups(
group(LegalAgent, CriticAgent)
.priority(1)
.returns(LegalReviewDto)
.returns(listOf(CritiqueDto)) // critic-agent is launched twice: a list, even of one
.toAgent(FullReviewAgent)
.returns(DealReportDto)
.toFinish(DealReportWorkflowFinish),
group(FastScreenAgent)
.priority(2)
.returns(FastScreenDto)
.toFinish(QuickRejectWorkflowFinish),
{ deadline: minutes(1) },
),
] as const;| Form | Meaning |
|---|---|
.joinQuorum({ min }) |
max defaults to min: after min counted answers go on, cancel the rest |
min |
how many counted answers are enough to go on |
max |
after how many counted answers to stop waiting and cancel the rest |
deadline |
how long the join waits — seconds(), minutes(), hours() (agboss/units), never a bare number |
.joinQuorumGroups(group(…).priority(n)<path>, …, { deadline }) |
go on when a group is complete, along that group's own path; min / max do not apply |
.acceptedBy(X) |
an arbiter every answer passes before it counts — between the launch and the quorum |
.acceptedBy(X).acceptedBy(Y) |
the only AND: in path order, the first rejection stops the chain |
.acceptedBy(anyOf(X, Y)) |
tried in order, the first that accepts accepts (S57) — the cheap code arbiter first |
Options are always an object: .joinQuorum(2) does not compile and the untyped builder refuses it.
.acceptedBy(…).join() does not compile; nothing follows .joinQuorumGroups(…) but
.onJoinError(E, <side path>). A quorum follows .toParallelAgents(…) only — arbiters judge
agents' answers.
- A group is
group(A, B, …); a repeated agent in it means "any of its copies", written n times n copies are needed. It is complete when all its places are counted. -
.priority(n)right aftergroup(…): 1 is the highest; equal numbers (or none) are equal groups — the first complete one wins. - Every group continues its own path:
group(…).priority(n).returns(…).toAgent / .toAction / .toRouter / .toWorkflow / .toFinish(…); a group without a continuation does not compile (and is[graph.dead-end]for the untyped builder). A node reached from two groups (or a group and a route, or.onJoinError) has its own path, by the general rules —agent(FullReviewAgent)…..joinQuorumGroupscounts as one nesting level ([flow.path-too-deep]).
The counted answers enter the path scope as the values the join's .returns(…)
names; the shape comes from the launch, not from the group:
| After | A single agent's value | A repeated class, or several agents with one output
|
|---|---|---|
.join() |
required XDto (optional for an OnDemand / Skip route) |
listOf(XDto) |
.joinQuorum({ min }), min = agents launched |
required XDto
|
listOf(XDto) |
.joinQuorum({ min }), any other min
|
optional(XDto) |
listOf(XDto) |
group(A, B…) of .joinQuorumGroups(…)
|
required XDto (the group's agents only) |
listOf(XDto), even when the group counts one copy |
The typed builder checks it against the generated JoinOutputs: a quorum's singles are the launch's
entry made optional unless min is the number of agents (one check, Min extends Count), and each
group has an entry of its own, keyed <launch> group(<agents as listed>). agboss check checks it
again on the quorum itself — a required XDto after .joinQuorum({ min: 2 }) of three agents is
[flow.quorum-return-not-optional] fit-review-workflow-start → toParallelAgents(skills-score-agent,
culture-score-agent, salary-fit-agent): .joinQuorum({ min: 2 }) may leave skills-score-agent out of
the quorum — write .returns(optional(SkillsScoreDto)), not .returns(SkillsScoreDto) (or min: 3 to
count every agent)
@Arbiter (arbiters/<name>.arbiter.ts, a name ending with -arbiter) decides whether an
arriving answer counts — once per answer; it is not a router and never chooses where to go. How it
decides is declared with decidedBy; nothing is inferred from its members:
-
DecidedBy.Model—model,prompt(how to judge) andcriteria: [{ agent, prompt }], one per agent of the launch; thepromptnever stands in for a missing criterion — an agent whose answers need no real check gets an explicit one ("Any non-empty answer"). -
DecidedBy.Code— one@OnAnswer(Agent) from<Agent>(answer: XDto): boolean(orPromise<boolean>) method per agent class; the answer is the plain DTO, which copy or item it is comes fromexecutionContext.step.copyNumber/.itemIndex(deps: [ExecutionContext]). The decorator checks the method: a wrong answer type, a return type other thanbooleanor a second required parameter does not compile. A rule shared by several agents is a private method each one calls;@OnAnswertakes exactly one class.
The other strategy's options do not compile (criteria on a code arbiter, deps on a model one).
A criterion or a method applies to every copy of a repeated agent and every item of a per-item
launch.
| Code | |
|---|---|
[flow.quorum-impossible] |
min above the agents launched (per item: above maxItems); a group listing an agent the launch does not run, or more copies than it runs |
[flow.quorum-return-not-optional] |
a required XDto after .joinQuorum({ min }) that may leave its agent out |
[flow.returns-mismatch] |
a quorum's or a group's .returns(…) names something else than its counted answers give |
[input.missing-on-path] |
a required field whose producer may be left out — the message names the agent and the fix optional(…) / listOf(…)
|
[arbiter.not-an-arbiter] |
.acceptedBy(X) of a class without @Arbiter
|
[arbiter.unhandled-agent] |
an agent of the launch without a criterion / an @OnAnswer method |
[arbiter.unknown-agent] |
a criterion / method for an agent the launch does not run |
[arbiter.duplicate-agent] |
two criteria / methods for one agent class |
[arbiter.answer-method-on-model] |
@OnAnswer on a DecidedBy.Model arbiter |
[arbiter.method-name] |
the method judging CompanyCheckAgent is not named fromCompanyCheck
|
[name.kind-suffix] |
an arbiter's name without -arbiter
|
agboss describe prints the quorum as written (.acceptedBy(anyOf(…)), .joinQuorum({ min: 2, max: 3, deadline: seconds(30) }), each group) and, for a list a quorum counts, each producer's
counts: full-review-agent ← deal: deal-workflow-start, legal: legal-agent (optional), critiques: critic-agent ×1–2 — a group counts at least the copies it lists and at most those launched, a
.joinQuorum(…) from none to all.
A quorum is decided inside its launch's superstep (S26), as answers arrive — never by waiting for every branch and deciding at the join:
- Each branch runs its agent and then its arbiter chain under one
maxConcurrencyslot and in its own step frame (a code arbiter'sexecutionContext.stepis the branch's:copyNumber,itemIndex). The.acceptedBy(…)steps are an AND in path order — the first rejection stops the chain, later arbiters are not called; insideanyOf(A, B)the arbiters run in order and the first that accepts accepts. - The answer is recorded at once, synchronously after its chain:
BranchVerdict.Countedwith a monotonicacceptedSequencewhile places are left,Rejected(withrejectedBy: the arbiters that said no — every one of ananyOf),Surpluswhen it was accepted after the quorum was already decided. - Once the outcome is fixed —
maxanswers counted (maxdefaults tomin), a group has won (no higher-priority group can still complete),minor every group can no longer be reached, every branch is through, or the deadline passed — the launch is aborted: the branches still running or waiting for a slot endBranchStatus.Cancelled; a code check still running is abandoned, a model check's call (which cannot be stopped) is awaited for its cost; neither counts. - The join decides from the branch records only: the counted answers in counting order
(
acceptedSequence— never listed or finish order) go into the path scope; for groups the winning group's agents' answers, on that group's own path. Not reached →QuorumNotReachedError(step.quorum, aStepFailedErrornaming the launching step, withminandcounted) into.onJoinError(QuorumNotReachedError, …), else the execution fails with it.
| What happens in a branch | Its record |
|---|---|
| its answer counted |
Finished, Counted, acceptedSequence
|
| an arbiter rejected it |
Finished, Rejected, rejectedBy
|
| accepted after the quorum was decided |
Finished, Surplus
|
| still running (or checked) when the quorum was decided | Cancelled |
its own step failed (StepFailedError) |
Skipped — under a quorum it only leaves its place empty |
| an arbiter threw, returned no boolean, or its model did not answer |
Failed with ArbiterError (step.arbiter): the launch fails, .onJoinError(ArbiterError, …) takes it |
a limit, app.cancel
|
as for every launch (#273 S21): the launch fails / the execution is cancelled |
Every branch's spend counts — a cancelled or surplus branch's too.
deadline (seconds(), minutes(), hours()) is turned into deadlineAt once, when the launch is
dispatched, from the application's Clock, and kept in the launch's ticket (the state and every
branch's payload). A Clock sleep ends the quorum at that moment: min reached → go on (groups: the
highest-priority complete group), else QuorumNotReachedError ("the deadline passed"). A pause
counts as elapsed time. The time its branches wait in the tree's queue (#264
maxConcurrentExecutions) does not: the deadline moves by it, in the process that runs the launch
(FinOps → Concurrency of a tree); a model call's wait for a
provider's rate limit (#266) is not excluded. A QuorumNotReachedError past the moved deadline says so: the deadline passed (2026-01-05T09:01:10.000Z: the ticket's deadline plus 40 s in the tree's queue). The time in the queue
is kept by the process that runs the launch only: after a resume or a crash the next pass starts it
again from 0, so an already-passed ticket deadline cancels the unfinished branches at once. Tests move it with the VirtualClock
(await clock.advance(seconds(31)), Testing → The clock).
A branch may pause inside a quorum (an approval); its siblings finish in the same superstep and their
records stay in the checkpoint's pending writes. On resume — or when another instance recovers the
execution after a crash (Several instances) — the quorum is rebuilt from those
records (counts, max(acceptedSequence), verdicts), so only the unfinished branches run and counting
goes on where it stopped. If deadlineAt has passed by then, the unfinished branches are cancelled
without running and the join decides from the records.
A DecidedBy.Model arbiter makes one decision per answer, on its own model: a decision model gets
one Decision.noul — the agent's criterion — over the arbiter's prompt and the answer as JSON; a
chat model answers through one answer tool (answer-arbiter) with a boolean. P(yes) > 0.5 counts.
Its spend is recorded under arbiter:<name>; the execution's budgets are checked before every call.
The arbiter's prompt and criteria are part of the workflow's prompt version, its model of the model
version, and its model is a model use like a router's (arbiters.<name> in agboss describe and
agboss check --models). A code arbiter is created by the application's container at start.
result.launches adds, per quorum launch, isQuorumReached, group (the winning group's agents
and priority) and, per branch, verdict, acceptedSequence and rejectedBy. The branch's trace
entry carries the same; the join adds a trace entry "quorum decided" and the observer event
onQuorumDecided (Observability).
agboss generate arbiter in-range --workflow src/panel/panel.workflow.ts writes
arbiters/in-range.arbiter.ts: a code arbiter with one @OnAnswer(Agent) from<Agent>(answer: ArbiterAnswerOf<typeof Agent>): boolean method per distinct agent of the flow's launch (--agent <name> picks the launch when there are several). It is not put into the flow — attach it with
.acceptedBy(InRangeArbiter) where it belongs.
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)