Darkwood Blog Blog
  • Articles
  • Watch
  • Releases
  • Creators
en
  • de
  • fr
Login
  • Blog
  • Articles
  • Watch
  • Releases
  • Creators

🙏 When orchestration guarantees execution, not value

on August 9, 2026

Log in to add a reaction to this post

🚀 1

What happens when an orchestration system executes work that has no value?

That is not a rhetorical question. It is an engineering experiment.

I built a multi-agent pipeline on Darkwood Flow. Five agents do useful work. Then I added a sixth.

It has no tools. It knows nothing about the codebase. It never reviews a pull request. It never fixes a bug. It simply says:

Thanks.

or:

Looks good.

The orchestration system was perfectly happy with it.

That turned out to be the interesting part.

Activity is not throughput

Five agents in the experiment do useful work: fetch documentation, read git/PR metadata, probe PHP capabilities, extract open-source AI principles, compute a cost framing. A sixth agent — ThanksAgent — analyzes nothing, transforms nothing, holds no context, and returns a constant social signal.

Yet the sixth agent still:

  • gets scheduled
  • occupies concurrency
  • generates Flow events
  • appears in observability
  • consumes messages
  • contributes to orchestration overhead
  • creates the impression of activity

Value produced = 0.

This is the central distinction of the article:

Activity is not throughput.

Execution is not value.

Orchestration can guarantee that work runs. It cannot guarantee that the work is useful.

And the line that should sit next to every multi-agent dashboard:

The scheduler cannot distinguish productive work from performative work.

If we want agent systems to optimize for value rather than activity, value has to become part of the runtime’s observable model.

Five useful agents and one useless one

The demo lives in a Symfony 8 console project: thanks-agent under content/thanks-agent. It is not a Symfony framework tour. It is a Darkwood Flow load demo with a thin CLI shell.

Each agent is a Flow JobInterface implementation. Value weights are explicit:

Agent Value Role
FetchDocumentationAgent +25 Load source excerpts
GithubAgent +40 PR / git metadata
PHPAgent +30 PHP 8.6 capability probe
MozillaAgent +20 Open-source AI principles
CostAgent +15 Outcome-cost framing
SummaryAgent +15 Fan-in synthesis
ThanksAgent +0 Always "Thanks." / "Looks good." / "Good job."

The Thanks Agent itself is almost aggressively boring:

final class ThanksAgent extends AbstractAgent
{
    private const RESPONSES = ['Thanks.', 'Looks good.', 'Good job.'];

    protected function produce(AgentPacket $packet): array
    {
        $output = self::RESPONSES[array_rand(self::RESPONSES)];

        return [
            'output' => $output,
            'tokens' => 2,
            'messages' => 28, // noise: many empty acknowledgements
            'didWork' => false,
            'payload' => [
                'analyzed' => false,
                'transformed' => false,
                'expertise' => null,
                'contextHeld' => false,
            ],
        ];
    }
}

It has excellent availability. It has never introduced a regression. It also has never produced anything.

Run the comparison:

php bin/console thanks-agent:compare --both --save

The machine will schedule it, meter it, and report it — with the same seriousness it grants to agents that actually work.

What exactly does an agent consume?

This is where the joke becomes engineering.

“Cost” is not one number. In a multi-agent runtime, a participant can consume:

Dimension What it means
Scheduler slot WIP under MaxIpStrategy
Wall-clock time Latency visible to humans
I/O wait Streams, sockets, HTTP
CPU Parsing, scoring, serialization
Tokens Model usage (simulated in this demo)
Messages Chatty acknowledgements, tool chatter
Context Memory / prompt / index slices
Logging / tracing Observability volume
Human review Attention, decision queue
Validation capacity Tests, gates, acceptance

The Thanks Agent barely touches CPU or tokens. It still burns scheduler slots, messages, events, and narrative oxygen.

That is the organizational pattern in miniature: visibility and signaling are easier to measure than outcomes, so systems drift toward rewarding whatever is loud, present, and constantly “green.”

Humans do this. Teams do this. CI does this. Agents will do this — unless the runtime models value explicitly.

Darkwood Flow as it exists today

Before claiming that Flow “solves agents,” be precise about what Flow is.

Darkwood Flow (darkwood/flow, PHP ≥ 8.5) is a linear, FBP-inspired asynchronous pipeline:

flowchart LR
    IP[Ip] --> Job[Job]
    Job --> Driver[Driver]
    Driver --> Events[PUSH PULL POP ASYNC POOL]
    Events --> Job

Conceptual model:

Ip → Job → Driver

You compose stages with FlowFactory, push information packets, and await() until the stream drains. Concurrency is many IPs in flight, capped by strategies such as MaxIpStrategy — not a first-class DAG of branches and joins.

Flow is not ReactPHP. It is not Amp. It does not try to replace them. Optional drivers can sit on those backends; the interesting question for Darkwood is:

How little runtime machinery does Flow need to orchestrate real asynchronous PHP work?

Core pieces used in the demo:

  • Flow\FlowFactory
  • Flow\Ip
  • Flow\JobInterface
  • Flow\Driver\FiberDriver (default)
  • Flow\Driver\StreamSelectDriver (generator jobs + stream_select)
  • Flow\IpStrategy\MaxIpStrategy
  • Events: PUSH, PULL, POP, ASYNC, POOL — and, newly, COMPLETE / ERROR

Two drivers matter for this article.

FiberDriver runs each job inside a PHP Fiber. Cooperative delays call Fiber::suspend(). It is the default path for thanks-agent:compare. One practical caveat showed up in measurement: Fiber delay resolution in the current driver is coarse enough that wait-heavy agent jobs cluster near one-second walls. That is a driver detail, not a claim about “agent intelligence.”

StreamSelectDriver is different: jobs may return a Generator that yields wait tokens — waitReadable, waitWritable, waitDelay — and the driver multiplexes those waits with native stream_select(). This is the path used by thanks-agent:bench-io’s Flow runner and by --driver=stream_select on compare. It is experimental in Flow’s own docs, and that is fine: the Thanks Agent experiment needs real readiness waiting, not usleep theatre.

MaxIpStrategy is the concurrency dial. It wraps another IP strategy (linear FIFO by default) and refuses to pull additional packets while processing >= max. That is runtime concurrency — not supervision capacity. Confusing the two is how you end up with six terminals and one exhausted human.

flowchart TB
    subgraph fanOut [Fan-out via multiple Ips]
        Dispatch[Push one Ip per agent]
        Dispatch --> Doc[FetchDocumentation]
        Dispatch --> Gh[Github]
        Dispatch --> Php[PHP]
        Dispatch --> Moz[Mozilla]
        Dispatch --> Cost[Cost]
        Dispatch --> Thanks[Thanks]
    end
    subgraph fanIn [Fan-in outside the graph API]
        Doc --> Store[RunStore]
        Gh --> Store
        Php --> Store
        Moz --> Store
        Cost --> Store
        Thanks --> Store
        Store --> Summary[SummaryAgent]
        Summary --> Board[Scoreboard]
    end

That honesty matters. The demo resembles scatter/gather. Flow does not yet expose a general DAG engine. Pretending otherwise would be Thanks Agent behaviour applied to documentation.

Flow does not know what “useful” means

A runtime understands operational semantics:

  • push / pull / pop
  • async dispatch
  • pool depth
  • success / error
  • duration

It does not automatically know whether the string "Thanks." created value.

So value must be modeled explicitly.

In the demo, App\Model\AgentId::valueWeight() and App\Instrumentation\ValueRegistry assign production scores. didWork: false forces ThanksAgent to score zero even if someone later forgets the weight table.

Promoted into Flow for reuse:

namespace Flow\Instrumentation;

final readonly class ValueTag
{
    public function __construct(
        public string $label,
        public int $value,
    ) {}
}

ValueTag is a primitive. The opinionated agent scoreboard stays in the application. That split is intentional: anything agent-specific stays in thanks-agent; anything about run accounting graduates to Flow.

Cost without value

Simulated costs, real scheduling

Important honesty: in this experiment, token costs are simulated. The demo ledger uses a fixed rate:

public const RATE_PER_1K_TOKENS = 0.002;

I/O is fixture files plus delayed socket pairs — not live LLM API bills. Wall-clock times and Flow event counts are measured. Dollar figures are illustrative accounting so the shape of the problem is visible.

The demo wires instrumentation onto Flow’s existing event bus — it does not invent a parallel telemetry system:

final class MetricsSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            Event::PUSH => ['onPush', -100],
            Event::PULL => ['onPull', -100],
            Event::POP => ['onPop', -100],
            Event::ASYNC => ['onAsync', -100],
        ];
    }

    public function onPull(PullEvent $event): void
    {
        // FiberDriver polls PULL in a tight loop; only meter productive pulls.
        if ([] !== $event->getIps()) {
            $this->ledger->recordPull();
        }
    }
}

That onPull guard is itself a lesson: if you meter every busy-loop poll as “work,” your FinOps dashboard becomes a fan fiction of the event loop.

The compare run (PHP 8.5.4, FiberDriver)

From generated reports under var/reports/ (compare-*-20260808-144317.json):

Metric Without Thanks With Thanks Delta
Value 145 145 0
Cost (simulated) 0.012474 0.022094 +0.00962
Messages 19 48 +29
Tokens (simulated) 887 897 +10
Wall time (ms) 958.47 1002.48 +44
Flow push/pull/pop/async 5 / 5 / 5 / 5 6 / 6 / 6 / 6 +1 each

ThanksAgent alone in that run:

Field Value
Value 0
Messages 28
Tokens 2
Cost 0.008904
didWork false
Output "Good job."

Useful output unchanged. Resources up. Value delta zero.

That is the whole thesis in a table.

The scoreboard’s verdict line is not marketing copy — it is printed by the command:

The sixth agent adds no value; it still consumes scheduling, context, logging, and observability.

Cost per request vs cost per useful result

Raw request accounting is a weak unit. An AI bill is closer to rate × consumption × tokenizer behaviour (and retries) than to the sticker price on a rate card. The unit that matters is cost per successful outcome, not cost per request.

ThanksAgent makes the pathology obvious:

  • request cost can be tiny
  • useful results = 0
  • cost per useful result tends toward infinity

A cheap agent that produces nothing is not cheap. It is subsidized noise.

The promoted Flow ledger exposes the same shape of metric:

$ledger->recordComplete('GithubAgent', tokens: 100);
$ledger->recordError('CostAgent', tokens: 32);

$snapshot = $ledger->snapshot();
// totals.costPerSuccessfulOutcome = totalCost / successes

Supervision is a scarce resource too

Once the experiment can run six agents without complaint, another question appears: adding execution capacity eventually moves the bottleneck.

Distinguish carefully:

Capacity What it measures
Model concurrency How many model calls you can fire
Runtime concurrency How many jobs Flow keeps in flight
Useful throughput Accepted / merged / validated outcomes
Human supervision How many open contexts a person can track
Validation capacity Automated reject/accept bandwidth

Agents do not tire. Supervisors do. Moving work to remote sandboxes can redistribute supervision across a team; it does not invent infinite attention.

The organizational metaphor emerges from the runtime itself. If the scheduler will happily host a participant that only says “Looks good.”, humans will eventually be asked to watch that participant too — or to ignore it while it still occupies WIP, logs, and dashboards. Parallel agents create inventory: branches, pull requests, open questions, half-finished contexts. The human becomes the data bus. Waiting is not necessarily waste; intolerance of idle terminals is what creates fatigue.

Read through Theory of Constraints eyes: when you remove one bottleneck, inventory accumulates in front of the next. Agent parallelism reduces machine wait. It can increase human wait. Little’s Law is the informal warning label — more work-in-progress at a fixed service rate means longer lead times — without pretending this demo measured a factory floor. What the experiment does show is simpler: you can add an agent that increases messages and scheduling events while leaving aggregate value untouched.

Operational consequence for orchestration policy: prefer one well-specified task with an automated acceptance path over four vague tasks that all route questions back to the same brain. Multi-agent only scales when validation does not.

Stop measuring how many tasks are running. Measure how many decisions are waiting.

That is why Flow now ships a resource budget, not a superstition:

use Flow\Budget\SupervisionBudget;

$budget = new SupervisionBudget(5);

if (!$budget->tryAcquire()) {
    // reassign, queue, or refuse — do not silently spawn more agents
}

$concurrency = $budget->clampConcurrency($requested);

max = 5 is a convenient default for demos, not a law of nature. The abstraction matters more than the number: concurrency is a scarce supervised resource, and the runtime should make exhaustion explicit.

Roadmap (not shipped): decision-queue depth metrics — open questions waiting on humans, crumb-time histograms, validation-readiness gates before fan-out. The demo proves the need; Flow does not yet meter human wait inventory.

Scatter/gather without pretending Flow is a DAG

The demo’s orchestration factory is the honest heart of the architecture:

$flow = $this->flowFactory->create(static function () use ($execute, $errorJob, $concurrency, $dispatcher) {
    yield [
        'job' => $execute,
        'errorJob' => $errorJob,
        'ipStrategy' => new MaxIpStrategy($concurrency),
        'dispatcher' => $dispatcher,
    ];
}, ['driver' => $driver]);

foreach ($agentIds as $agentId) {
    $flow(new Ip(new AgentPacket($agentId, $runId)));
}
$flow->await();

// Fan-in outside the driver loop
$summaryAgent->executeSync(new AgentPacket(AgentId::Summary, $runId));

What this actually does:

  1. Scatter — one Ip per agent identity
  2. Parallelism — MaxIpStrategy caps in-flight work
  3. Shared bag — each agent writes an AgentResult into RunStore
  4. Join — after await(), SummaryAgent reads the bag synchronously
  5. Scoreboard — application-level report, not a Flow graph node
flowchart LR
    Ips[Multiple Ips] --> Stage[ExecuteAgentJob]
    Stage --> Store[RunStore]
    Store --> Summary[SummaryAgent executeSync]
    Summary --> Report[RunReport]

Why not call this a DAG? Because Flow’s public topology is still a linked list of stages. Fan-in is application state. That is fine for a demonstration. It is insufficient if you need typed joins, partial failures per branch, or reusable graph compilation.

A real join/DAG API would be justified when:

  • multiple producers must synchronize with schema, not a shared mutable bag
  • cancellation must propagate across branches
  • the same graph is reused across products, not one demo

Until then, honesty beats aspirational diagrams.

Async is waiting efficiently

The second command:

php bin/console thanks-agent:bench-io --save

compares four runners on the same six wait-heavy tasks (five useful + Thanks), using synthetic delayed sockets on PHP 8.5.4:

Runner Wall ms Value Status
sequential 599.9 130 baseline
stream_select 166.8 130 ~3.6× wall improvement
php86_poll — — skipped (Io\Poll\Context unavailable on 8.5.4)
flow_stream_select 178.7 130 Flow StreamSelectDriver (~+12 ms vs raw select)

Context: local synthetic I/O, simulated token costs, ThanksAgent included, value still 130 (no Summary in the I/O bench task set).

Sequential execution is easy to reason about and expensive in wall time when the work is wait-bound. stream_select() multiplexes readiness. Flow’s StreamSelectDriver wraps generator jobs that yield wait tokens (waitReadable / waitWritable / waitDelay).

PHP 8.6’s native Poll API (Io\Poll\*) is the more interesting long-term primitive: epoll/kqueue-class backends where available, typed with Time\Duration. Critically, polling is not an event loop. Userland still owns scheduling.

Flow’s answer is a thin interface — not an Amp clone:

interface PollerInterface
{
    /**
     * @param list<resource> $read
     * @param list<resource> $write
     * @return array{0: list<resource>, 1: list<resource>}
     */
    public function poll(array $read, array $write, float $timeoutSeconds): array;

    public function name(): string;
}

Implementations today: StreamSelectPoller, NativePollPoller (falls back when PHP 8.6 Poll is missing). Flow\IO\Duration bridges toward Time\Duration when present. clamp() is used in SupervisionBudget when available.

flowchart TB
    Jobs[Jobs] --> Scheduler[Flow Driver / Scheduler]
    Scheduler --> Poller[PollerInterface]
    Poller --> OS[OS mux stream_select or Io_Poll]
    OS --> Ready[Ready I/O]
    Ready --> Scheduler
    Scheduler --> Cont[Job continuation]

Modest conclusion only:

In this local benchmark, multiplexing wait-heavy tasks cut wall-clock time from ~600 ms sequential to ~167 ms with stream_select, and Flow’s StreamSelect driver stayed in the same order of magnitude (~179 ms). PHP 8.6 Poll was not measured here because it is not available on 8.5.4.

No claim that Flow is “faster than React/Amp.” Those libraries were not in the bench.

Polling is not value either

Here is the editorial callback.

A better poller can execute useless work faster.

Improving epoll, Fibers, concurrency limits, or cloud agent density does not solve the Thanks Agent problem. It amplifies whatever the system is optimizing.

Performance amplifies whatever the system is optimizing.

If the optimization target is activity, you get more activity.

If the optimization target is successful outcomes, you get useful throughput.

The I/O bench still scores ThanksAgent at value 0 while giving it a scheduler slot. Faster mux, same emptiness.

The unhappy path must be billed

Symfony AI pull request #2363 is an external technical lesson, not a Darkwood dependency. The relevant insight:

Streaming runs can attach usage metadata and then throw (for example max-output). If accounting only finalizes on the happy path, the provider still billed you, and your ledger lied.

The PR’s direction: mutually exclusive terminals — complete vs error — with explicit error listeners.

Flow now mirrors that lifecycle at the event layer:

  • Flow\Event::COMPLETE → CompleteEvent
  • Flow\Event::ERROR → ErrorEvent
  • Flow\Instrumentation\CostLedger::recordComplete / recordError
  • CostLedgerSubscriber can listen to both
flowchart TB
    Start[Start] --> Exec[Execute]
    Exec --> Success[Success]
    Exec --> Failure[Failure]
    Exec --> Abandon[Abandon / Cancel]
    Success --> Complete[CompleteEvent]
    Failure --> Error[ErrorEvent]
    Abandon --> Hole[Accounting hole today]
    Complete --> Ledger[CostLedger]
    Error --> Ledger

Demo command:

php bin/console thanks-agent:run --simulate-error --with-thanks

CostAgent is forced down the failure path; tokens are still recorded. Useful value drops; the ledger does not pretend the failure was free.

Limitation stated explicitly: abandoned / cancelled work without an exception is still an open design hole. The demo has only App\Orchestration\CancellationTicket — a stub, not Flow lifecycle. Until abandon is a first-class terminal, FinOps can still miss that case. Symfony AI’s PR is careful about the same class of hole; so should we be.

Context is a resource too

A coding agent does not magically “understand” an entire repository. It understands the slice its index and tools provision. Incomplete language coverage produces confident nonsense. Index quality and model quality are separate variables.

For Flow, “agent executed successfully” is a weak statement. A serious run attribution eventually needs:

  • model
  • tools
  • context / CodeMap freshness
  • prompt
  • verification policy
  • cost
  • outcome

Not shipped: a CodeMap port. Not shipped: an AgentRouter that treats price as one field among completion rate, latency, modality, verification cost, and concurrency eligibility. Those remain roadmap items forced by the experiment: once you can measure value versus cost, you discover how little “ran successfully” actually tells you.

Provider neutrality is the other long constraint. Prefer ownership of the orchestration layer over renting a closed vertical stack. Flow’s job is to stay provider-neutral — a Symfony-native place where models are swappable ports, not the product identity.

Cost per successful outcome

Bring outcome economics, routing, and ThanksAgent together.

Strategy Looks cheap Often is
Lowest $/token On the invoice line Wrong if retries explode
Smallest model always On the rate card Wrong if completion rate collapses
Most agents in parallel On the dashboard Wrong if decision queue explodes
ThanksAgent On tokens (2) Infinite per useful result

A more expensive model that finishes correctly once can beat a cheap model that fails three times. A slower sequential run with automated validation can beat a parallel swarm that parks five PRs on one human.

The demo’s costPerSuccessfulOutcome field is simulated arithmetic. The shape is what matters for production systems: meter terminals, attribute retries, divide by outcomes you actually care about.

What this changed in Darkwood Flow

Already implemented / promoted into Flow

Verified in darkwood/src/Darkwood/Component/Flow/:

Primitive Role
Flow\Instrumentation\CostLedger Outcome-cost accounting
Flow\Instrumentation\CostLedgerSubscriber Event → ledger bridge
Flow\Instrumentation\ValueTag Production-value label
Flow\Event::COMPLETE / ERROR Terminal lifecycle
Flow\Event\CompleteEvent / ErrorEvent Terminal payloads
Flow\Budget\SupervisionBudget WIP / supervision gate
Flow\IO\PollerInterface Thin mux abstraction
Flow\IO\StreamSelectPoller stream_select backend
Flow\IO\NativePollPoller PHP 8.6 Poll with fallback
Flow\IO\Duration Seconds helper → native Duration when available

Docs: Flow instrumentation page under the component docs tree. Tests cover ledger, budget, and poller fallback behaviour.

One wiring nuance matters for readers who clone the demo: the Symfony app still meters primarily through App\Instrumentation\MetricsSubscriber on PUSH/PULL/POP/ASYNC. Flow’s COMPLETE/ERROR events and CostLedgerSubscriber are promoted primitives ready for adoption; the demo’s --simulate-error path records failure into the application ledger today. That is the “demo proves, Flow owns” migration in progress — not a claim that every terminal in the CLI already dispatches CompleteEvent.

Partial Function Application from PHP 8.6 is worth a short mention and no more: binding fixed configuration into pipeline callables ($invoke = $platform->invoke($model, ?)) could thin some Flow DSL noise once 8.6 is baseline. The codebase does not depend on it yet. Forcing it into the narrative would be performative — which would be ironic.

Demonstrated but still application-level (thanks-agent)

  • Agent jobs (ThanksAgent, …)
  • ValueRegistry / scoreboard UX
  • RunStore scatter bag
  • App\Instrumentation\CostLedger + MetricsSubscriber (demo-shaped, agent-aware)
  • CLI: thanks-agent:compare, thanks-agent:bench-io, thanks-agent:run
  • CancellationTicket stub

Future work (roadmap, not claims of presence)

  • Native PHP 8.6 Poll driver integrated into the await loop (beyond Poller helper)
  • First-class cancellation + abandon terminal policy
  • Decision-queue metrics
  • True graph / join nodes if scatter/gather demand hardens
  • AgentRouter, CodeMap, ModelPort
  • Partial function application as a thinner pipeline DSL when PHP 8.6 is baseline

Do not inflate the roadmap. The point of Thanks Agent was to force the first layer of truthfulness: value and cost belong in the runtime’s vocabulary.

Closing

We already know how to make agents run.

Fibers, pollers, cloud sandboxes, and multi-agent CLIs will keep getting better at producing motion. Motion is easy to dashboard. Motion is easy to celebrate. Motion is what a Thanks Agent optimizes for by construction.

The harder problem is deciding which work should exist at all — and proving, in numbers the runtime can see, that the work produced outcomes worth their resources.

Once an orchestration system will happily execute a participant that contributes nothing, the next optimization is probably not another agent.

It is a better definition of value.

If we want agent systems to optimize for value rather than activity, value has to become part of the runtime’s observable model.

Darkwood Flow’s job, after Thanks Agent, is clearer: keep orchestrating asynchronous PHP work with as little machinery as possible — and refuse to treat agent chatter as throughput.

References

Technical material

  • Frédéric Bouchery — I Stopped Running Several Agents (WIP, validation chains, human decision as constraint)
  • Mozilla — Open Source AI Strategy (ownership of the orchestration layer)

Ideas about codebase indexes, agent routing, and AI bill variance circulate widely in the industry; this essay uses them as engineering constraints, not as commentary on any single publication.

Darkwood artifacts

  • Demo: https://github.com/matyo91/thanks-agent
  • Slides: https://github.com/matyo91/slidewire

Log in to add a reaction to this post

🚀 1

Site

  • Sitemap
  • Contact
  • Legal mentions

Network

  • Hello
  • Blog
  • Apps
  • Photos

Social

Darkwood 2026, all rights reserved