Skip to content

Commit 7d85ba8

Browse files
authored
feat(core): parallel branch yields with consensus-aware merge (#60)
- Introduce yields(String...) on parallel branches for structured domain output via the inject→execute→extract pipeline. - Rewrite ConsensusEvaluator to use structured BranchResult data, fixing bugs in heuristic-based vote extraction. - Extract AgentLifecycleRunner to unify agent execution across StandardNodeExecutor and ParallelNodeExecutor, restoring missing listener events on branches. Resolve: #59 Signed-off-by: Aleksandr Suvorov <asuvorov@hensu.io>
1 parent 11048d6 commit 7d85ba8

60 files changed

Lines changed: 2485 additions & 2168 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ hensu-langchain4j-adapter # LangChain4j integration (Claude, GPT, Gemini, De
100100
- `WorkflowStateRepository` - Tenant-scoped storage for execution state snapshots (defaults to in-memory; JDBC impl in server)
101101
- `ExecutionListener` - Lifecycle callbacks including `onCheckpoint(HensuState)` for inter-node persistence
102102
- `ProcessorPipeline` - Orchestrates pre/post node execution processor chains
103+
- `EngineVariables` - SSOT for engine-managed variable names (`score`, `approved`, `recommendation`)
104+
- `AgentLifecycleRunner` - Stateless agent call lifecycle (resolve → enrich → lookup → listener → execute → convert); shared by `StandardNodeExecutor` and `ParallelNodeExecutor`
105+
- `SynchronizedListenerDecorator` - Thread-safe `ExecutionListener` wrapper for parallel branch execution
103106

104107
**Execution Lifecycle**: The `WorkflowExecutor` processes each node through a strict PRE-EXECUTE-POST pipeline.
105108
Pre-execution processors run in order: checkpoint → node start. Post-execution processors run in order: output
@@ -142,7 +145,7 @@ Workflow
142145

143146
- `StandardNode` - Regular step with agent execution, typed state variable output (`writes`), and transitions
144147
- `LoopNode` - Iterative execution with break conditions
145-
- `ParallelNode` - Concurrent execution with consensus
148+
- `ParallelNode` - Concurrent execution with consensus; branches declare domain output via `yields()`
146149
- `ForkNode` - Spawn parallel execution paths
147150
- `JoinNode` - Await and merge forked paths
148151
- `GenericNode` - Custom execution logic via registered handlers
@@ -154,7 +157,7 @@ Workflow
154157

155158
- `SuccessTransition` / `FailureTransition` - Based on execution result
156159
- `ScoreTransition` - Conditional on rubric score (e.g., score >= 80 → approve)
157-
- `ApprovalTransition` - Routes on the `approved` boolean engine variable (written via `writes("approved")`); falls through if absent or non-boolean
160+
- `ApprovalTransition` - Routes on the `approved` boolean engine variable (injected by `ApprovalVariableInjector`); falls through if absent or non-boolean
158161
- `AlwaysTransition` - Unconditional
159162

160163
### State Schema
@@ -374,6 +377,11 @@ CRUD operations, UPSERT semantics, FK constraints, tenant isolation, and seriali
374377
- `hensu-core/.../workflow/state/WorkflowStateSchema.java` - Typed state variable schema (optional per-workflow declaration)
375378
- `hensu-core/.../workflow/state/StateVariableDeclaration.java` - Single variable declaration (name, type, isInput)
376379
- `hensu-core/.../workflow/state/VarType.java` - Variable type enum (STRING, NUMBER, BOOLEAN, LIST_STRING)
380+
- `hensu-core/.../execution/EngineVariables.java` - SSOT for engine-managed variable names (score, approved, recommendation)
381+
- `hensu-core/.../execution/SynchronizedListenerDecorator.java` - Thread-safe listener wrapper for parallel branches
382+
- `hensu-core/.../execution/executor/AgentLifecycleRunner.java` - Shared agent call lifecycle (resolve → enrich → execute)
383+
- `hensu-core/.../execution/parallel/BranchExecutionConfig.java` - Per-branch config carried on ExecutionContext
384+
- `hensu-core/.../execution/enricher/YieldsVariableInjector.java` - Injects yield format instructions for branch prompts
377385
- `hensu-core/.../workflow/transition/ApprovalTransition.java` - Boolean approval routing via `approved` engine variable
378386
- `hensu-core/.../workflow/validation/WorkflowValidator.java` - Load-time validator for `writes` and prompt variable references
379387

docs/developer-guide-core.md

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ This guide covers API usage, adapter development, extension points, and testing
1111
- [Execution Pipeline](#execution-pipeline)
1212
- [Pre-Execution Pipeline](#pre-execution-pipeline)
1313
- [Post-Execution Pipeline](#post-execution-pipeline)
14+
- [Parallel Branch Concurrency](#parallel-branch-concurrency)
1415
- [Agentic Output Validation](#agentic-output-validation)
1516
- [Creating Custom Adapters](#creating-custom-adapters)
1617
- [Generic Nodes](#generic-nodes)
@@ -194,6 +195,29 @@ step, enabling self-correcting loops.
194195
rule that returns a valid target node ID wins, and the state is updated to point to that next node. Throws
195196
`IllegalStateException` if no rule matches, preventing the workflow from silently getting stuck.
196197

198+
### Parallel Branch Concurrency
199+
200+
`ParallelNodeExecutor` runs each branch on a virtual thread with two concurrency guarantees:
201+
202+
**1. ScopedValue isolation** – each branch receives an isolated context snapshot bound via
203+
`ScopedValue.where(BRANCH_CONTEXT, snapshot)`. Branch mutations never leak into sibling branches
204+
or the parent state. This is the same mechanism used for tenant isolation (`TenantContext`).
205+
206+
> **Extension rule:** Never use `ThreadLocal` in branch-aware code. `ScopedValue` is the only
207+
> safe context propagation mechanism for virtual threads. See `10-java-standards.md` for details.
208+
209+
**2. SynchronizedListenerDecorator** – the parent `ExecutionListener` is wrapped in a
210+
`SynchronizedListenerDecorator` before branch submission. Every callback is `synchronized` so that
211+
multi-line output (e.g., box-drawing in verbose CLI, SSE event frames) completes atomically without
212+
interleaving across concurrent branches.
213+
214+
Custom `ExecutionListener` implementations do **not** need their own synchronization when used with
215+
parallel nodes – the decorator handles it. Only the parallel execution path pays the synchronization
216+
cost; sequential nodes use the raw listener.
217+
218+
> **JEP 491 (Java 24+):** Virtual thread pinning on `synchronized` blocks was eliminated. Monitor
219+
> contention on I/O-bound listener callbacks is negligible for typical branch counts (3–10).
220+
197221
### Agentic Output Validation
198222

199223
LLM-generated outputs are non-deterministic and treated as **untrusted data**. Unlike REST input (short, user-typed,
@@ -711,28 +735,41 @@ Validation is a no-op when no schema is declared. Legacy workflows always pass t
711735

712736
## Engine Variable Injection
713737

714-
Before each agent call, `StandardNodeExecutor` runs `EngineVariablePromptEnricher` to append
738+
Before each agent call, `AgentLifecycleRunner` runs `EngineVariablePromptEnricher` to append
715739
format requirements to the resolved prompt. The enricher is a dumb iterator over an ordered chain
716740
of `EngineVariableInjector`s — each one self-contained, deciding independently whether it applies.
717741

742+
Engine variable names (`score`, `approved`, `recommendation`) are defined in the `EngineVariables`
743+
class — the single source of truth for all engine-managed variable keys.
744+
718745
### Injection Pipeline
719746

747+
Each injector fires when **either** of two conditions is met:
748+
1. The node has a matching transition rule (e.g., `ScoreTransition` for `ScoreVariableInjector`)
749+
2. The execution context carries a `BranchExecutionConfig` where `needsSelfScoring()` returns true
750+
(consensus branch with a non-JUDGE_DECIDES strategy)
751+
720752
```mermaid
721753
flowchart LR
722-
r(["RubricPromptInjector"]) -->|"rubricId != null"| s(["ScoreVariableInjector"])
723-
s -->|"has ScoreTransition"| a(["ApprovalVariableInjector"])
724-
a -->|"has ApprovalTransition"| rec(["RecommendationVariable\nInjector"])
725-
rec -->|"has Score/Approval\nTransition"| w(["WritesVariableInjector"])
754+
r(["RubricPromptInjector\n· rubricId != null"]) --> s(["ScoreVariableInjector\n· ScoreTransition or\nconsensus branch"])
755+
s --> a(["ApprovalVariableInjector\n· ApprovalTransition or\nconsensus branch"])
756+
a --> rec(["RecommendationVariable\nInjector\n· Score/Approval or\nconsensus branch"])
757+
rec --> w(["WritesVariableInjector\n· has writes()"])
758+
w --> y(["YieldsVariableInjector\n· has yields()"])
726759
727760
style r fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px
728761
style s fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px
729762
style a fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px
730763
style rec fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px
731764
style w fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px
765+
style y fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px
732766
733767
linkStyle default stroke:#0A84FF, stroke-width:1px
734768
```
735769

770+
Each injector runs unconditionally in order. The condition listed under each name is when that
771+
injector **appends** its instruction – otherwise it passes the prompt through unchanged.
772+
736773
### Description Hints in WritesVariableInjector
737774

738775
When a variable is declared with a description in the state schema, `WritesVariableInjector` includes it in the appended instruction:
@@ -757,6 +794,7 @@ EngineVariablePromptEnricher enricher = new EngineVariablePromptEnricher(
757794
new ApprovalVariableInjector(),
758795
new RecommendationVariableInjector(),
759796
new WritesVariableInjector(),
797+
new YieldsVariableInjector(),
760798
new MyCustomInjector() // appended after built-ins
761799
));
762800
```
@@ -1268,7 +1306,11 @@ Environment variables matching `*_API_KEY`, `*_KEY`, `*_SECRET`, or `*_TOKEN` pa
12681306
| `plan/Planner.java` | Planner interface (`createPlan` / `revisePlan`) |
12691307
| `plan/StaticPlanner.java` | Resolves predefined `plan { }` steps from `PlanningConfig` |
12701308
| `plan/LlmPlanner.java` | LLM-based plan generation and revision (`DYNAMIC` mode) |
1309+
| `execution/EngineVariables.java` | SSOT for engine variable names (`score`, `approved`, `recommendation`) |
1310+
| `execution/SynchronizedListenerDecorator.java` | Thread-safe listener wrapper for parallel branch execution |
1311+
| `execution/executor/AgentLifecycleRunner.java` | Composition-based agent call: prompt enrichment → agent execution → output extraction |
12711312
| `execution/executor/AgenticNodeExecutor.java` | Drives preparation + execution `PlanPipeline`s for `StandardNode` |
1313+
| `execution/parallel/BranchExecutionConfig.java` | Typed branch metadata on `ExecutionContext` (consensus strategy, yields list) |
12721314
| `template/SimpleTemplateResolver.java` | `{variable}` substitution |
12731315
| `review/ReviewHandler.java` | Human review interface |
12741316
| `execution/pipeline/ProcessorPipeline.java` | Orchestrates pre/post processor chains |

docs/developer-guide-serialization.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,13 +236,36 @@ Java records have no inner `Builder` class — Jackson deserializes them via the
236236
237237
### Current registrations
238238

239+
**Snapshot hierarchy** (embedded in `ExecutionStep`):
240+
239241
| Class | Embedded in | Reason |
240242
|------------------------------------|----------------------------------|---------------------------------------------|
241243
| `HensuSnapshot` | `ExecutionStep.Builder.snapshot` | Canonical constructor + component accessors |
242244
| `PlanSnapshot` | `HensuSnapshot.planSnapshot` | Nested record inside `HensuSnapshot` |
243245
| `PlanSnapshot.PlannedStepSnapshot` | `PlanSnapshot.steps()` | Nested record inside `PlanSnapshot` |
244246
| `PlanSnapshot.StepResultSnapshot` | `PlanSnapshot.results()` | Nested record inside `PlanSnapshot` |
245247

248+
**Parallel execution types** (manually deserialized in `NodeDeserializer`, but serialized via default Jackson `BeanSerializer` in `WorkflowSerializer.toJson()`):
249+
250+
| Class | Embedded in / Context | Reason |
251+
|-----------------------------|-------------------------------------------------|-------------------------------------------------------------|
252+
| `Branch` | `ParallelNode` branch list | Record with `yields` (`List<String>`) component |
253+
| `ConsensusConfig` | `ParallelNode` consensus configuration | Record with strategy enum + nullable threshold |
254+
| `ConsensusStrategy` | `ConsensusConfig.strategy()` | Enum – Jackson needs reflection to serialize enum constants |
255+
| `ConsensusResult` | State context map (checkpoint serialization) | Stored under `consensus_votes` key during execution |
256+
| `ConsensusResult.Vote` | `ConsensusResult.votes()` map values | Inner record with vote type, score, weight |
257+
| `ConsensusResult.VoteType` | `ConsensusResult.Vote.voteType()` | Enum (APPROVE / REJECT) |
258+
| `ScoreCondition` | `ScoreTransition` condition list | Nested record inside transition rules |
259+
| `DoubleRange` | `ScoreCondition.range()` | Nested record inside `ScoreCondition` |
260+
261+
> **Why not `treeToValue`?** These types are simple records with primitive/string/enum fields.
262+
> Manual `JsonNode` extraction in `NodeDeserializer` avoids reflection during deserialization.
263+
> Registration is needed only for the **serialization** path (`WorkflowSerializer.toJson()`),
264+
> where Jackson's default `BeanSerializer` reads component accessors reflectively.
265+
266+
> **`Branch.yields`** is a `List<String>`. `NodeDeserializer` extracts it by iterating the JSON
267+
> array node manually — no `treeToValue` or `convertValue` needed for `List<String>`.
268+
246269
Unlike the `treeToValue` pattern, these records do **not** need a custom deserializer or mixin. Registration alone is sufficient because Jackson's built-in record support handles the canonical constructor mapping.
247270

248271
### Checklist before adding a new record type

docs/developer-guide-server.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1268,15 +1268,19 @@ Registrations are split across four dedicated classes to keep each concern isola
12681268

12691269
#### NativeImageConfig — Hensu domain model
12701270

1271-
**Three patterns require registration here:**
1271+
**Five patterns require registration here** (matching `NativeImageConfig` javadoc §1–§5):
12721272

12731273
1. **`@JsonPOJOBuilder` mixin targets** — Jackson instantiates the builder via its private no-arg constructor, calls each setter, then calls `build()`. GraalVM cannot trace these calls through the generic mixin machinery.
12741274

12751275
2. **`treeToValue` delegation** — When a custom deserializer calls `mapper.treeToValue(node, SomeClass.class)`, Jackson uses POJO reflection for `SomeClass`. Simple records (primitives, strings, enums only) should be **fixed** by switching to manual `JsonNode` extraction instead. Register only types where manual extraction is impractical (e.g., nested `Duration` fields).
12761276

1277-
3. **Record types embedded in builder classes**When a `record` is a field inside a mixin-registered builder type, Jackson reaches it via its canonical constructor and component accessors. GraalVM cannot trace those calls statically. Register the record and every nested record transitively. No mixin or custom deserializer is needed — registration alone is sufficient.
1277+
3. **Manual deser, default Jackson ser**Types deserialized manually in `NodeDeserializer` (direct `JsonNode` extraction) but serialized via Jackson's default `BeanSerializer` in `WorkflowSerializer.toJson()`. The deserialization path needs no reflection, but the serialization path reads record component accessors reflectively. Current types: `Branch`, `ConsensusConfig`, `ConsensusStrategy`, `ConsensusResult`, `ConsensusResult.Vote`, `ConsensusResult.VoteType`, `ScoreCondition`, `DoubleRange`.
12781278

1279-
**When to add vs. fix:** if the class is a simple record with no `Duration`/nested-complex fields, fix the deserializer. If it contains `Duration` or deeply nested types, add it here. For records embedded in builder types, always register them. See [hensu-serialization Developer Guide](developer-guide-serialization.md#the-treetovalue-rule) for the full rule.
1279+
4. **Simple immutable types with custom deser but default ser**`WorkflowStateSchema` and `StateVariableDeclaration` use a custom deserializer (`WorkflowStateSchemaDeserializer`) that extracts fields manually. However, Jackson's default serializer reads `getVariables()` reflectively, so both classes must be registered.
1280+
1281+
5. **Record types embedded in builder classes** — When a `record` is a field inside a mixin-registered builder type, Jackson reaches it via its canonical constructor and component accessors. GraalVM cannot trace those calls statically. Register the record and every nested record transitively. No mixin or custom deserializer is needed — registration alone is sufficient. Current types: `ReviewConfig`, `HensuSnapshot`, `PlanSnapshot` hierarchy.
1282+
1283+
**When to add vs. fix:** if the class is a simple record with no `Duration`/nested-complex fields, fix the deserializer. If it contains `Duration` or deeply nested types, add it here. For records embedded in builder types, always register them. For types in pattern 3, registration is needed only because the serialization path uses default Jackson. See [hensu-serialization Developer Guide](developer-guide-serialization.md#the-treetovalue-rule) for the full rule.
12801284

12811285
#### LangChain4j*NativeConfig — third-party provider DTOs
12821286

0 commit comments

Comments
 (0)