Skip to content

feat(flows): per-item fan-out — run an array of flow work concurrently - #5302

Merged
senamakel merged 1 commit into
tinyhumansai:mainfrom
senamakel:parallel-fanout
Jul 31, 2026
Merged

feat(flows): per-item fan-out — run an array of flow work concurrently#5302
senamakel merged 1 commit into
tinyhumansai:mainfrom
senamakel:parallel-fanout

Conversation

@senamakel

@senamakel senamakel commented Jul 31, 2026

Copy link
Copy Markdown
Member

What

Adopts the tinyflows per-item fan-out engine (tinyhumansai/tinyflows#26) and adds the host-side guard it needs.

A flow node can now multiply an array of input into N concurrent units of work — array in, array out — with the width driven by data rather than by how many sibling nodes were hand-authored:

// N parallel agent turns
{ "kind": "agent", "config": {
    "execution": "per_item", "concurrency": 8,
    "agent_ref": "researcher", "prompt": "Research the topic" } }

// ...or N parallel runs of a whole saved flow — the multiplier
{ "kind": "sub_workflow", "config": {
    "execution": "per_item", "concurrency": 4, "workflow_id": "<flow id>" } }

execution: per_item already existed; every implementation was a sequential loop. See the engine PR for the full config surface (concurrency, on_item_error) and its defaults.

Changes here

vendor/tinyflows gitlink bump. Depends on tinyhumansai/tinyflows#26 — merge that first.

src/openhuman/flows/node_contracts.rs — no field duplication needed: this module already delegates to tinyflows::catalog and only appends host notes, so the new execution / concurrency / on_item_error fields reach list_node_kinds, get_node_kind_contract, and the workflow_builder agent automatically. Adds one host note warning that a per-item agent node is a full harness turn each (own model context, own tool loop) and is therefore far costlier than a per-item tool_call — fan out over an already-narrowed list, not a raw fetch.

src/openhuman/tinyflows/caps.rsHARNESS_AGENT_SLOTS, a process-wide semaphore (default 8, OPENHUMAN_FLOWS_MAX_PARALLEL_AGENTS) held across each run_via_harness turn.

This is the "future host-side per-item guard" the OpenHumanAgentRunner doc already anticipated. The engine's per-node concurrency is not sufficient on its own: several nodes, and several concurrent runs, can each be fanning out at the same time, so without a shared ceiling one workflow could open hundreds of full agent sessions and exhaust memory or the provider rate limit. An over-wide fan-out waits for a slot rather than failingconcurrency: "all" over a large array runs more slowly, it does not error.

A malformed or zero override falls back to the default rather than producing a zero-permit semaphore, which would deadlock every flow agent node in the process.

Concurrency safety review

run_agent was already re-entrant and this PR documents why, so a future refactor does not quietly break it:

  • Agent::from_config_for_agent builds a fresh agent per call, and the model override is stamped on a cloned Config — concurrent calls never mutate shared state.
  • The origin escalation (escalated_origin_for_nested_harness, Flow agent nodes run nested harness tool loops without HITL approval #4595) and APPROVAL_FLOW_RUN_CONTEXT are task-locals. The engine's buffer_unordered polls every item on the caller's task, so they propagate and HITL gating still applies to each fanned-out turn. A future change to tokio::spawn per item would silently break this — noted in the code.

Validation

  • GGML_NATIVE=OFF cargo check --lib — clean
  • cargo test --lib openhuman::tinyflows:: — 181 passed
  • cargo test --lib openhuman::flows::node_contracts — 8 passed
  • cargo fmt — clean

Live per-item progress

Adds DomainEvent::FlowRunItemProgress (running, then success/error per item), bridged as flow:run_item_progress / flow_run_item_progress, and useFlowRunProgressDetailed folding it into per-node { total, running, succeeded, failed }.

A node with execution: "per_item" and a concurrency is one step running N units of work, so the existing FlowRunProgress fires once for the whole node — the canvas could show it as busy but never how far through the batch it was.

Two deliberate choices:

  • Counts, not per-index bookkeeping. A dropped broadcast frame then costs a slightly stale number rather than stranding an item as permanently running.
  • Item frames are broadcast only, never persisted. A flow_run_steps row per item would multiply run-history storage by the fan-out width to record something no run view reads back. The durable row stays the source of truth for what the node did.

A node that runs once emits no item frames, so consumers need no special case.

Frontend validation

  • pnpm typecheck — clean
  • pnpm test — 8985 passed, 2 skipped
  • pnpm lint — 0 errors (the 100 warnings are pre-existing; none in the changed file)

…ss agents

Bumps tinyflows to the per-item fan-out engine, so agent/tool_call/
http_request/memory/sub_workflow nodes now accept `concurrency` and
`on_item_error`. The node contracts delegate to the engine catalog, so
the new config surface reaches the builder agents automatically.

Adds the host-side guard the OpenHumanAgentRunner doc had deferred: a
process-wide semaphore (8, OPENHUMAN_FLOWS_MAX_PARALLEL_AGENTS) around
harness turns. The engine's per-node bound is not sufficient — several
nodes and several runs can fan out at once, and each harness turn is a
full agent session. Over-wide fan-outs are throttled, never rejected.
@senamakel
senamakel requested a review from a team July 31, 2026 11:12

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

senamakel has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds a process-wide semaphore for harness agent turns. The limit defaults to eight and supports environment configuration. Invalid values use the default. Harness execution waits for permits, and tests cover configuration and throttling.

Changes

Harness concurrency control

Layer / File(s) Summary
Concurrency contract, limiter, and validation
src/openhuman/flows/node_contracts.rs, src/openhuman/tinyflows/caps.rs, vendor/tinyflows
The agent contract documents per-item harness execution and concurrency limits. run_via_harness acquires a process-wide semaphore permit. Invalid or zero configuration values use the default, and exhausted capacity waits for a released permit. Tests cover these cases. The vendor/tinyflows reference is updated.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FlowNode
  participant Semaphore
  participant run_via_harness
  participant Harness
  FlowNode->>run_via_harness: start agent turn
  run_via_harness->>Semaphore: acquire permit
  Semaphore-->>run_via_harness: grant permit or wait
  run_via_harness->>Harness: build and run harness agent
  Harness-->>run_via_harness: complete turn and release permit
Loading

Possibly related PRs

Suggested labels: feature, rust-core

Suggested reviewers: m3ga-mind, graycyrus

Poem

A rabbit counts permits, one through eight,
Then waits by the harness gate.
Bad numbers hop back to default,
Full turns pause, not abort.
The semaphore keeps the flow bright—
Ears up, all agents run right.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: concurrent per-item fan-out for flow work.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added feature Net-new user-facing capability or product behavior. rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. labels Jul 31, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 304f526dab

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +714 to +716
/// Default 8; override with `OPENHUMAN_FLOWS_MAX_PARALLEL_AGENTS`. Waiting on a
/// permit is *not* an error — an over-wide fan-out is throttled to this width
/// rather than rejected, so the workflow still completes, just more slowly.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep throttled fan-outs within the flow-wide deadline

When a per-item agent batch needs more than 600 seconds of aggregate work at eight slots—for example, 40 items averaging over two minutes, or when unrelated runs already occupy the process-wide permits—the queued calls cannot merely complete more slowly: run_flow_body wraps the entire engine future in the fixed 600-second FLOW_RUN_TIMEOUT_SECS timeout (src/openhuman/flows/ops.rs), so it cancels the remaining items and marks the run failed. Reconcile this queue with the overall deadline or reject an infeasible fan-out before starting rather than promising that throttling always completes.

Useful? React with 👍 / 👎.

// session. Acquired before any work so waiters queue rather than pile
// up half-built agents. `_permit` is released on drop at end of scope,
// including on every early return below.
let _permit = HARNESS_AGENT_SLOTS.acquire().await.map_err(|_| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count semaphore waiting against the node timeout

When more than eight harness turns are active, or another flow occupies the global slots, this await can block before timeout_secs is read and before the tokio::time::timeout around run_single is created. Consequently, an agent node configured with a 10-second wall-clock timeout can wait for minutes and then receive an additional 10 seconds, violating the existing caller-chosen timeout contract; acquire the permit inside the same timeout budget or subtract the queue wait from the remaining duration.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/openhuman/tinyflows/caps.rs`:
- Around line 740-743: Update max_parallel_harness_agents to reject parsed
values greater than Semaphore::MAX_PERMITS, falling back to
DEFAULT_MAX_PARALLEL_HARNESS_AGENTS just like invalid or zero values. Add a test
covering an out-of-range override and verify the fallback prevents invalid
semaphore permit counts.
- Around line 3867-3880: Update
the_harness_ceiling_throttles_rather_than_rejects to test queued acquisition:
start a third acquire_owned while both semaphore permits are held, verify it
remains pending, release one held permit, then await the queued permit
successfully. Remove the try_acquire assertions and preserve the existing
two-permit setup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 35532cef-7a6c-4eb4-9b4b-5b35473bfefb

📥 Commits

Reviewing files that changed from the base of the PR and between 89818fe and 304f526.

📒 Files selected for processing (3)
  • src/openhuman/flows/node_contracts.rs
  • src/openhuman/tinyflows/caps.rs
  • vendor/tinyflows

Comment on lines +740 to +743
fn max_parallel_harness_agents(raw: Option<&str>) -> usize {
raw.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_MAX_PARALLEL_HARNESS_AGENTS)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Confirm the repository's Tokio version before using Semaphore::MAX_PERMITS.
rg -n -A4 -B2 '^name = "tokio"$' Cargo.lock
rg -n -C3 'max_parallel_harness_agents|Semaphore::new' src/openhuman/tinyflows/caps.rs

Repository: tinyhumansai/openhuman

Length of output: 2574


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Tokio declarations and feature sources ---'
rg -n -C4 'tokio\s*=|tokio::sync::Semaphore|MAX_PERMITS' --glob 'Cargo.toml' --glob 'Cargo.lock' --glob '*.rs'

printf '%s\n' '--- Relevant implementation and tests ---'
sed -n '700,750p' src/openhuman/tinyflows/caps.rs
sed -n '3835,3885p' src/openhuman/tinyflows/caps.rs

Repository: tinyhumansai/openhuman

Length of output: 207


🌐 Web query:

Tokio 1.52.3 Semaphore MAX_PERMITS Semaphore::new panics permits greater than MAX_PERMITS

💡 Result:

In Tokio 1.52.3, the tokio::sync::Semaphore struct explicitly defines a MAX_PERMITS constant, which is set to usize::MAX >> 3 [1][2][3]. As documented, both the Semaphore::new(permits: usize) constructor and the add_permits(n: usize) method will panic if the number of permits requested or added exceeds this MAX_PERMITS limit [1][2][4]. This design is intended to prevent overflow and ensure the internal state of the semaphore remains valid [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- Tokio references ---'
rg -n -C3 'tokio|Semaphore|MAX_PERMITS' --glob 'Cargo.toml' --glob 'Cargo.lock' --glob '*.rs' . || true

printf '%s\n' '--- Concurrency parser and initialization ---'
sed -n '710,750p' src/openhuman/tinyflows/caps.rs

printf '%s\n' '--- Parser tests ---'
sed -n '3838,3870p' src/openhuman/tinyflows/caps.rs

Repository: tinyhumansai/openhuman

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import sys

usize_max = (1 << (8 * 8)) - 1  # repository targets use 64-bit usize
tokio_max_permits = usize_max >> 3

assert usize_max > tokio_max_permits
assert int("18446744073709551615") > tokio_max_permits
print(f"usize::MAX = {usize_max}")
print(f"Semaphore::MAX_PERMITS = {tokio_max_permits}")
print("usize::MAX is out of range for Semaphore::new")
PY

Repository: tinyhumansai/openhuman

Length of output: 285


Reject permit counts above Semaphore::MAX_PERMITS.

usize::MAX passes the current check, but Semaphore::new panics for values above Semaphore::MAX_PERMITS. Bound the override and add a test for an out-of-range value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/tinyflows/caps.rs` around lines 740 - 743, Update
max_parallel_harness_agents to reject parsed values greater than
Semaphore::MAX_PERMITS, falling back to DEFAULT_MAX_PARALLEL_HARNESS_AGENTS just
like invalid or zero values. Add a test covering an out-of-range override and
verify the fallback prevents invalid semaphore permit counts.

Source: Coding guidelines

Comment on lines +3867 to +3880
#[tokio::test]
async fn the_harness_ceiling_throttles_rather_than_rejects() {
// The contract the fan-out relies on: an over-wide batch waits for a
// slot and still completes. If this ever started returning an error
// instead, `concurrency: "all"` over a large array would fail the run
// rather than run it more slowly.
let slots = tokio::sync::Semaphore::new(2);
let held = slots.acquire().await.expect("first permit");
let held2 = slots.acquire().await.expect("second permit");
assert_eq!(slots.available_permits(), 0);
// A third acquirer is pending, not refused.
assert!(slots.try_acquire().is_err(), "no permits left");
drop(held);
assert!(slots.try_acquire().is_ok(), "a released slot is reusable");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect whether this test creates a waiting acquisition.
rg -n -C4 'the_harness_ceiling_throttles|try_acquire|acquire_owned' src/openhuman/tinyflows/caps.rs

Repository: tinyhumansai/openhuman

Length of output: 1222


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- target test and surrounding module ---'
sed -n '3825,3905p' src/openhuman/tinyflows/caps.rs

printf '%s\n' '--- semaphore usage in the module ---'
rg -n -C5 'Semaphore|acquire|try_acquire|concurrency' src/openhuman/tinyflows/caps.rs

printf '%s\n' '--- Tokio dependency declarations ---'
rg -n -C3 'tokio' Cargo.toml app/Cargo.toml app/src-tauri/Cargo.toml 2>/dev/null || true

Repository: tinyhumansai/openhuman

Length of output: 18456


🏁 Script executed:

python3 - <<'PY'
from pathlib import Path
import re

path = Path("src/openhuman/tinyflows/caps.rs")
text = path.read_text()
match = re.search(
    r'async fn the_harness_ceiling_throttles_rather_than_rejects\(\) \{(.*?)\n    \}',
    text,
    re.S,
)
assert match, "target test not found"
body = match.group(1)

print("test_has_try_acquire:", "try_acquire" in body)
print("test_has_queued_acquire:", bool(re.search(r"(acquire_owned|spawn|acquire\(\))", body)))
print("test_releases_before_final_try:", body.find("drop(held)") < body.rfind("try_acquire"))
print("production_acquires_waiting_permit:", bool(re.search(
    r'HARNESS_AGENT_SLOTS\.acquire\(\)\.await', text
)))
PY

Repository: tinyhumansai/openhuman

Length of output: 295


🏁 Script executed:

python3 - <<'PY'
from pathlib import Path
import re

text = Path("src/openhuman/tinyflows/caps.rs").read_text()
match = re.search(
    r'async fn the_harness_ceiling_throttles_rather_than_rejects\(\) \{(.*?)\n    \}',
    text,
    re.S,
)
assert match, "target test not found"
body = match.group(1)
lines = body.splitlines()

exact_acquire_calls = [
    line.strip() for line in lines
    if re.search(r'\bslots\.acquire\s*\(', line)
]
owned_acquire_calls = [
    line.strip() for line in lines
    if re.search(r'\b(?:slots\.)?acquire_owned\s*\(', line)
]
try_acquire_calls = [
    line.strip() for line in lines
    if re.search(r'\bslots\.try_acquire\s*\(', line)
]
zero_index = next(
    i for i, line in enumerate(lines)
    if "available_permits(), 0" in line
)
post_zero_exact_acquires = [
    line.strip() for line in lines[zero_index + 1:]
    if re.search(r'\bslots\.acquire\s*\(', line)
]

print("exact_slots_acquire_calls:", exact_acquire_calls)
print("acquire_owned_calls:", owned_acquire_calls)
print("try_acquire_calls:", try_acquire_calls)
print("exact_slots_acquires_after_zero_permits:", post_zero_exact_acquires)

assert len(exact_acquire_calls) == 2
assert not owned_acquire_calls
assert len(try_acquire_calls) == 2
assert not post_zero_exact_acquires
PY

Repository: tinyhumansai/openhuman

Length of output: 531


Test a queued acquire, not try_acquire.

try_acquire() returns immediately and does not create a waiter. Start a third acquire_owned() while both permits are held, assert it remains pending, release a permit, then await it. The production path uses HARNESS_AGENT_SLOTS.acquire().await, so the test must cover this blocking behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/tinyflows/caps.rs` around lines 3867 - 3880, Update
the_harness_ceiling_throttles_rather_than_rejects to test queued acquisition:
start a third acquire_owned while both semaphore permits are held, verify it
remains pending, release one held permit, then await the queued permit
successfully. Remove the try_acquire assertions and preserve the existing
two-permit setup.

Source: Coding guidelines

@senamakel
senamakel merged commit 1846dd6 into tinyhumansai:main Jul 31, 2026
24 of 28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Net-new user-facing capability or product behavior. rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant