🙏 When orchestration guarantees execution, not value
on August 9, 2026
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\FlowFactoryFlow\IpFlow\JobInterfaceFlow\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:
- Scatter — one
Ipper agent identity - Parallelism —
MaxIpStrategycaps in-flight work - Shared bag — each agent writes an
AgentResultintoRunStore - Join — after
await(),SummaryAgentreads the bag synchronously - 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→CompleteEventFlow\Event::ERROR→ErrorEventFlow\Instrumentation\CostLedger::recordComplete/recordErrorCostLedgerSubscribercan 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 UXRunStorescatter bagApp\Instrumentation\CostLedger+MetricsSubscriber(demo-shaped, agent-aware)- CLI:
thanks-agent:compare,thanks-agent:bench-io,thanks-agent:run CancellationTicketstub
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.