Shahzad Bhatti Welcome to my ramblings and rants!

September 11, 2026

What Happens After the PR Merges: Building the Learning Loop Software Factories Are Missing

Filed under: Computing — admin @ 8:51 pm

Turning every review comment, rubber-stamped PR, and missed acceptance criterion into a process improvement.


TL;DR

  • Large companies like Stripe, Uber, and Spotify merge thousands of agent-authored PRs a week. What’s unsolved is whether the organization gets smarter from them.
  • AI helps with technical debt (in code) but accelerates cognitive debt (in people) and intent debt (in artifacts) because people accept AI output without building real understanding.
  • Instead of removing the code review, this post suggests risk-stratified review by blast radius.
  • What most teams are missing is an audit layer, e.g., a periodic scan of the last N merged PRs that tells you whether your review process is actually working.
  • I have built a five-stage flywheel: a PR merges -> ygs-learn extracts what reviewers taught the system -> the next agent run reads those learnings -> periodic ygs-pr-audit and ygs-code-audit runs surface systemic patterns.

Agentic Engineering Maturity Model

When I started using unit testing and test driven development decades ago, it taught me that tests don’t eliminate bugs. Instead, they create a feedback loop that makes the next bug less likely. For example, you may write a test as part of the development process or as a result of a production bug that you had to fix. With each test, you build a regression suite that acts as a quality gate, and serves as an institutional memory of every mistake the teams made. Agentic engineering needs the same thing where they don’t just write code but a flywheel that turns every review comment or incident into process or a skill so that the organization gets better over time. For example, I see three levels of how teams use AI in their development workflow:

  • Pull systems: You ask an agent to do something, e.g., interactive sessions with Claude Code but the learning disappears when you close the session.
  • Push systems (“software factories” or “ambient agents”): The system asks the agent to do things continuously, e.g., pick up issues, write code, open PRs. Humans are then looped in when a decision needs judgment.
  • Learning systems: The push system that also records what it learned, closes the loop, and updates its own skills and guardrails. This allows the next run to get better because the organization’s captured knowledge improved.

I have been building automated workflows for the third level that I will share in this post.


What I’ve Already Built

Over the past year I’ve built three interconnected open-source systems, covered in two earlier posts:

I have built several declarative AI workflow definitions based on these primitives:

ai-gh-implement.yaml     — plan ? implement ? create-pr ? poll-pr
ai-gh-review.yaml        — automated code review on a single PR
ai-standup-gh.yaml       — daily standup brief from GitHub + Slack
ai-gh-pr-audit.yaml      — PR gap analysis ? skill improvement PR ? learn
ai-codebase-audit.yaml   — codebase archaeology across N commits
ai-gh-issue-picker.yaml  — intelligent issue triage and selection
ai-jira-implement.yaml   — same pipeline for Jira/Bitbucket repos
ai-jira-review.yaml      — code review for Bitbucket PRs
ai-jira-pr-audit.yaml    — PR audit for Jira-tracked repos
ai-adhoc.yaml             — run any skill from a Slack command

I also built 40+ skills, including ygs-implement, ygs-code-review, ygs-review-deep, ygs-security-review, ygs-sre-review, ygs-learn, ygs-pr-audit, ygs-codebase-audit, ygs-standup, ygs-risk-scan, ygs-sprint-plan, ygs-estimate, ygs-qa, and ygs-ship.

Slack Is the Interface

I have been using Slack to interact with the orchestration engine, e.g., an engineer types a command in a channel, Formicary picks it up, runs the right workflow on Kubernetes, and threads all status updates and results:

Engineer:  @bot implement acme/backend#142
Bot:       ? Planning implementation for issue #142...
Bot:       ? Plan ready — 3 files, estimated complexity: medium (using Sonnet)
Bot:       ?? Implementing... (48 turns)
Bot:       ? Self-review found 0 critical issues
Bot:       ? PR #287 opened: https://github.com/acme/backend/pull/287
Bot:       ? Watching PR for review comments...
  [2 hours later, reviewer comments on PR]
Bot:       ? Responding to 2 review comments, pushing fixes...
  [reviewer approves, PR merges]
Bot:       ? PR #287 merged. Running ygs-learn to extract learnings...
Bot:       ? 1 new learning captured: "Cache invalidation must check ACL per-request"

The ai-adhoc.yaml workflow lets engineers run any skill on demand so the full skill library is available without leaving Slack.

The Implement Pipeline

The workhorse workflow is ai-gh-implement, a six-task DAG:

plan ? implement ? self-review ? create-pr ? poll-pr ? done

In above workflow, self-review is invoked after the agent implements a change to review its own diff against the plan and the issue’s acceptance criteria. It can loop in human review if it finds any issues and the engineer resolves it before the PR even opens. Also, it uses a complexity-based model routing where the plan step estimates task complexity (low, medium, high) and writes it to an artifact. The implement then picks the model to match to keep the costs proportional to difficult.

The Learning Skills

Following are learning workflows that I built:

  • ygs-pr-audit: analyzes the last N merged PRs across four dimensions (spec, design, skills, process), surfaces systemic gaps with evidence, and produces a list of recommended skill updates.
  • ygs-codebase-audit: runs structural archaeology across N commits, detecting hotspots, knowledge silos, duplicate abstractions, test brittleness, and security gaps using git/grep/find commands against the codebase.
  • ygs-learn: fires automatically when a PR merges inside ai-gh-implement. The poll-pr task detects the merge and calls ygs-learn, which extracts atomic learnings from review comments and writes learning documents that future agent runs read.

The ygs-learn is wired into poll-pr so every merged PR automatically triggers learning extraction.


The Factory Floor Problem

The Software factory or ambient agents create a system that proactively respond to events (a new issue, a failing test, a merged PR) instead of waiting for a human to ask. For example, Igor Ostrovsky shared common patterns where a software factory starts work from events and coordinates agents through engineering workflows like agents reading/writing specs/issues/pull requests while people approve key decisions. He models it as a graph, e.g., nodes are artifacts, edges are agent-driven transformations, and approvals and CI checks gate progress. This maps onto what Formicary does: a DAG of Kubernetes tasks, where each task produces artifacts the next one consumes with exit-code-based routing and human decision points. Uber shared a similar layering in their “agentic SDLC platform“, a context graph, an LLM gateway, and a governance layer underneath their agents. The Formicary / ai-dev-tools / you-got-skills setup is a smaller, open-source version of the same idea.

Other large companies are using similar software factories:

  • Stripe’s Minions merge 1,000+ PRs per week in Ruby with human PR review as the gate.
  • Uber’s Minion opens roughly 11% of company-wide PRs directly andover 70% of all PRs agent-attributed.
  • Spotify’s Honk reports 650+ agent-authored merged PRs per month with up to 90% time savings on migration-style work.

Here’s what a Formicary workflow definition looks like in practice:

- task_type: poll-pr
  method: KUBERNETES
  timeout: 168h
  dependencies:
    - create-pr
    - poll-pr          # self-dependency: re-runs after PAUSE_JOB
  script:
    - python -m scripts.gh.check_pr_state --issue-id {{.IssueNumber}}
    - python -m scripts.gh.fetch_comments --issue-id {{.IssueNumber}}
    - python -m scripts.gh.respond_comments --issue-id {{.IssueNumber}}
  on_exit_code:
    3: PAUSE_JOB       # PR still open — wait, then re-run
  delay: "120s"        # check every 2 minutes

PAUSE_JOB allows a loop without blocking, e.g., poll-pr sees the PR is still open, it exits with code 3. Formicary pauses the task, waits 120 seconds, and re-runs it. When a reviewer leaves comments, the task fetches them, has Claude respond, and pushes fixes. When the PR merges, check_pr_state calls ygs-learn to extract learnings.

But none of above workflows answer a key question: What did the organization learn from the last 50 PRs that merged?Which review patterns are failing? Where are knowledge silos growing? What mistakes keep repeating? Are reviewers actually reviewing, or rubber-stamping?

The current wave of software factories optimizes the pipeline (issue to deploy) and treats code review as a throughput bottleneck to shrink. Some teams like StrongDM are removing it entirely. That may work for some organizations but it’s not practical for most enterprises. Code review isn’t just a quality gate. For example, Google’s internal engineering practices and a recent study of 3,100 practitioner opinions treat knowledge transfer as equally important as defect detection. Review is where junior engineers absorb senior engineers’ mental models and where architectural decisions are explained. It builds a shared understanding of how the system works or what Fred Brooks calls a conceptual integrity.


Three Kinds of Debt

Margaret-Anne Storey’s Triple Debt Model defined following terminology for what goes wrong when throughput outpaces understanding:

  • Technical Debt (Code): This is already known term for poor implementation choices that make systems harder to change, tangled dependencies, missing abstractions, etc. However, AI is actually helping with technical debt with automated refactoring and AI-generated test suites.
  • Cognitive Debt (People): Cognitive debt is the erosion of shared understanding across a team over time. The team can no longer confidently explain how the system works or predict the impact of a change. For example, when AI generates code, a developer might accept it without building real understanding of it. At scale, across a team, that becomes an accumulation of not knowing. Researchers call this cognitive surrender. In another follow-up post, Storey lists symptoms such as: engineers lose confidence making changes, debugging takes longer, onboarding takes longer, and code review overhead goes up. The fixes are inherently human: code review, pair programming, system walkthroughs, retrospectives.
  • Intent Debt (Artifacts): Intent debt is the absence of externalized rationale that explain what a system is for and how it should evolve. In other words, nobody wrote down why the system works this way, so nobody. Addy Osmani’s blog on intent-debt explains that the cost of undocumented intent used to be paid by humans touching the code but now every agent pays it. My skills repository like the docs/prd/, docs/adr/, and docs/learnings/ directories allow agents to make decisions based on what the system is actually trying to accomplish.

The Triple Debt in Practice

Debt TypeWhere It LivesWhat Fixes ItWhat Accelerates It
TechnicalCodeTests, static analysis, refactoringShipping without testing
CognitivePeopleReview, pairing, walkthroughs, onboardingAI-generated code accepted without understanding
IntentArtifactsADRs, specs, skills, learningsEvery agent session that starts without captured rationale

Generative AI reduce technical debt through automated refactoring and test generation but it accelerates cognitive and intent debt, because the team ends up producing code faster than it can build shared understanding of what that code does and why.


Why Humans Stay in Review

The answer isn’t to keep the review process as it was or remove it all together. It’s risk-stratified review based on risk and blast radius. For example, Meta published Automating Low-Risk Code Review (RADAR) that described a multi-stage funnel for deciding which changes can land without human review:

  1. Eligibility gates: is this diff type eligible for automation at all?
  2. Diff Risk Score: a machine-learned model predicting the likelihood of a production incident.
  3. LLM-based automated code review: an AI reviewer that can approve with high confidence.
  4. Deterministic validation checks: a final safety net.

Teams at Meta tune their own risk thresholds so that they can automate the low-risk changes and route the high-risk ones to humans. Qodo also ships a classifier that labels PRs by scope and sensitivity in context so that reviewers can triage their attention. That’s what we are trying to do in my organization so that for high-blast-radius changes like auth, billing, security plugins, etc., humans stay in the loop. Also, I built something that other doesn’t have: an audit layer that tells you whether your risk calibration is actually working.

The Audit Layer

RADAR-style gating prevents the risky merge from happening but my PR audit catches when the gating itself is failing. For example, when reviewers are rubber-stamping security changes, bot-flagged critical findings go unacknowledged, or when large architectural PRs ship with two comments. Here are examples of my PR audit runs (details anonymized):

MetricValueBenchmarkSignal
Rubber-stamp rate (high blast-radius)50%<10% healthy / >25% problem? Problem
Security review coverage0%? Gap
Bot-finding follow-through~80%>90% healthy / <70% gap?? Warning
Human review burden~78%<40% healthy / >70% overloaded?? Overloaded
Formal acceptance criteria coverage13%>80% healthy / <50% reactive? Critical

Half of the high-blast-radius PRs like auth, billing, and security got rubber-stamp approvals with little substantive comments. The code-review bot caught roughly 22% of substantive findings versus humans’ 78%, meaning the team was leaning heavily on human reviewers for architectural and security issues. In A practical guide to risk-based code review, Cortex’s research predicts that a reviewer’s ability to catch defects collapses past 200–400 lines of code. AI generates code faster than that threshold so approval rates rise while inline comments fall. The ygs-pr-audit skill identifies gaps in the automated review process so that we can improve skills, processes, specifications and other aspects of the development process.


The Flywheel for Learning Culture

In order to continuously improve your development process, you need a feedback loop for continuous learning. You can consider it as a learning flywheel for the workflows of entire software development process:

Stage 1: A PR merges. poll-pr detects it.

Every implementation workflow has a poll-pr task that checks PR state periodically. When the status flips to merged (or declined), the task exits the loop and triggers the next stage.

Stage 2: ygs-learn extracts learnings.

On merge, the system invokes ygs-learn in extract mode. Before touching review comments, it runs Phase 0: PR Health Check across five dimensions:

  • Spec coverage: Did the linked issue have formal acceptance criteria?
  • Design decisions: Were architectural choices documented, or did the PR just change code?
  • Security/SRE: Did the PR touch security-sensitive paths?
  • Review quality: How many substantive human comments? Was there a rubber-stamp?
  • CI health: Did CI pass cleanly, or were there flaky retries?

Phase 0 produces a one-paragraph health summary that becomes metadata on the learning. It also catches the edge case where a PR merges with no review comments at all. After Phase 0, ygs-learn enters extract mode. It fetches every review comment from the merged PR and asks: does this reveal a recurring pattern, a gotcha that would apply to future work? For each actionable insight, ygs-learn sorts it into one of seven categories: Edge Case, Integration Gotcha, Performance Cliff, Security Trap, Process Friction, Domain Rule, or Tooling Quirk and writes a structured document:

# Guard Bypass After Cache: Security Gate Evaluated Once at Load Time

**Category:** Security Trap
**Date:** 2026-09-08
**Source:** Review feedback on PR #XXXX — reviewer caught that
the authorization check runs once at connection creation, not per query

## Learning

If a security gate (e.g., isFeatureAllowed()) is checked once at load time
and the result is cached, the gate must also be checked before every use
of the cached object. Otherwise, a permission revocation after initial
load is silently ignored.

## Evidence

Database connection manager called isAllowed() once in getConnection(),
cached the client, and returned the cached client on subsequent calls.
A reviewer identified that revoking the feature flag mid-session would
have no effect.

## Application

When reviewing code that caches authenticated/authorized resources:
check whether the authorization check is repeated before each use,
not just at creation time.

Each learning is stored in docs/learnings/YYYY-MM-DD-slug.md with deduplication.

Stage 3: The next implementation run reads the learnings.

When the next ygs-implement pipeline runs, its planning and implementation steps load docs/learnings/ as context. If the last PR’s reviewer caught a cache-bypass vulnerability, the next agent run that touches caching code has that learning in front of it. This is similar to NVIDIA’s MAPE control loop for production AI agents: Monitor (poll-pr watches for the merge and collects review signals) -> Analyze (ygs-learn categorizes what happened and why it matters) -> Plan (writes learnings to docs/learnings/) -> Execute (the next implementation pipeline reads those learnings as context). Augment Code describes something similar like execute, coach, distill, improve in their Agent Learning Flywheel blog except their coaching happens synchronously in Slack but my extraction happens asynchronously after the PR closes.

Stage 4: ygs-pr-audit looks across many PRs and recommends systemic changes.

Individual learnings are useful but the real leverage comes from periodic audits that scan last N merged PRs at once and spot common patterns like is the rubber-stamp rate climbing or are bot findings getting ignored before merge? The key output for the flywheel is skill_improvements.json, a list of recommended changes to the repo’s skill files: create .claude/skills/security-review/SKILL.md, update .claude/docs/testing.md with a flaky-test-triage section, etc.

Stage 5: The audit opens a real PR with skill improvements.

The plan-skill-updates task reads the audit findings and writes an update plan. create-skill-pr implements those changes and opens a pull request against the repo. poll-pr then watches that skill-improvement PR, responds to human reviewer comments. The system learns from its own improvement process.

Every report includes a Positive Patterns section naming exemplary reviewers, e.g., the person who traced a security issue to its root cause, the reviewer who ran local tests to verify a bot-authored PR, etc. This is the blameless-postmortem move applied to code review. The postmortem maturity literature teaches that punishing people for incidents makes people stop surfacing incidents. Recognizing good work and documenting thoroughness are important design decisions.

Sharing Learnings Across the Organization

Though, individual repo learnings live in docs/learnings/ inside each repo but I created a shared skills repository you-got-skills for common skills like ygs-implement, ygs-code-review, ygs-security-review, and the rest. This shared repository is improved based on continuous learnings from the audit reports.


Inside the PR Audit: A Concrete Walkthrough

Let me show how the PR audit works in the format I used in Killing the State Machine, where every design choice is explained with its reasoning.

The workflow triggers from Slack:

@bot pr-audit acme/backend
or 
@bot pr-audit acme/backend --focus skills --n-prs 30

Formicary picks up the message, resolves it against the ai-gh-pr-audit job type, and runs a five-task DAG:

The audit-prs Task

The Python harness (run_pr_audit.py) does the heavy lifting before the LLM sees the data: clone the repo, fetch the last N merged PRs, classify every comment and check acceptance criteria. The LLM then invokes Claude with the ygs-pr-audit skill, which runs a disciplined 5-phase protocol:

  • Phase 1 Setup. Load shared references, check whether the repo has its own skill overrides in .claude/skills/ and read the pre-computed PR data.
  • Phase 2 Four specialist passes. This is the core analysis for the most common failure mode. Each PR is scored across all four dimensions and the findings are tagged by dimension ([SPEC], [DESIGN], [SKILL-GAP], [PRACTICE]).
  • Phase 2.5 Deep review escalation. Security-sensitive PRs and large, under-reviewed PRs get escalated to specialized deep-review skills.
  • Phase 3 Verification gate. This is to remove any false positive, e.g., the instructions are blunt: re-examine every finding, re-read the cited evidence, confirm the conclusion actually follows from the data.
  • Phase 4 Synthesis. Deduplicate findings, escalate severity, compute the metrics dashboard, and write the full report.

Here’s an anonymized executive summary from a real run:

[PRACTICE] Rubber-stamp approval on high blast-radius changes
Confidence: HIGH | gap_type: process | impact: increases_risk
Frequency: 4/8 high-blast-radius PRs (50%)

And here’s what a finding looks like in full:

[PRACTICE] Rubber-stamp approval on high blast-radius changes
Confidence: HIGH | gap_type: process | impact: increases_risk
Frequency: 4/8 high-blast-radius PRs (50%)

Evidence:
- PR #XXXX (infra registry, +587 LOC, 6 Terraform files):
  4 approvers, all rubber-stamp with 0 substantive comments.
- PR #YYYY (billing metrics, 6 files, +359/-184 LOC):
  2 approvers, both rubber-stamp, 0 substantive comments.
- PR #ZZZZ (security detection rule router, 12 files, +2058/-27 LOC):
  1 rubber-stamp approver; only substantive comments were merge-conflict messages.

Recommendation: Require ?1 substantive technical comment from at least one
approver on PRs touching auth/billing/security plugins/infra.
Track rubber-stamp rate on high-blast-radius PRs as a team health metric.

The most actionable part of the report is the Recommended Skill Updates table:

ActionSkill PathWhat to AddMotivated By
Create.claude/skills/security-review/SKILL.mdValidation patterns, RBAC header checks, IAM auditPRs with auth gaps
Update.claude/docs/testing.md“Flaky test triage” section: root-cause classification, prohibition on removing regression assertions3 reactive flaky-test fixes in one day
Update.claude/skills/sdet-pr-review/SKILL.mdBot-authored PR review bar: reviewer must document what they validatedBot PRs merged with zero substantive review
Create.claude/docs/review-standards.mdHigh-blast-radius taxonomy, bot-finding acknowledgment policyRubber-stamp patterns

These recommendations feed directly into plan-skill-updates, which writes concrete file changes, and create-skill-pr. The system doesn’t just report; it proposes, implements, and waits for human review before changing anything.

What the Engineer Sees in Slack

The full report posts to the Slack thread that triggered the audit:

? PR Audit Complete — acme/backend (50 PRs analyzed)

? 11 findings: 3 spec gaps · 2 design gaps · 2 skill gaps · 4 practice gaps

? Critical: 4/8 high-blast-radius PRs rubber-stamped
? Critical: 0% security review coverage
? Critical: Formal acceptance criteria missing in 94% of PRs
?? Warning: Bot findings acknowledged only ~80% of the time

? Skill improvement PR opened: #312
   ? Creates .claude/skills/security-review/SKILL.md
   ? Updates .claude/docs/testing.md
   ? Updates .claude/skills/sdet-pr-review/SKILL.md

Full report attached as pr_audit_report.md

Every workflow like implement, review, audit, standup follows the same pattern: trigger from Slack, thread all updates back to the same conversation, post results to the same thread.


The Codebase Audit: Structural Archaeology

The PR audit examines process like how PRs were reviewed, what gaps appeared along the way. The codebase audit examines structure like what’s accumulated in the code itself across hundreds of commits.

Triggered from Slack:

@bot codebase-audit acme/backend --focus security

The codebase audit runs actual commands against the codebase like git log, grep, find and reports findings with file-and-line evidence. It covers seven dimensions:

  1. Hotspots: files changed most frequently, and files with the highest bug-fix ratio.
  2. Architecture: module coupling, dependency direction violations, abstraction leaks.
  3. Security: hardcoded credentials, missing input validation, auth pattern violations.
  4. Duplicates: near-identical implementations across different modules.
  5. Test health: missing test files, timing-based tests, coverage gaps.
  6. SRE: missing health checks, observability, alerting for new endpoints, improper error handling.
  7. Knowledge silos: single-contributor ownership of critical paths.

An anonymized finding:

[SILO] 83% of auth/ commits by a single contributor over 6 months
Severity: HIGH | Confidence: HIGH

Evidence: `git log --since="6 months ago" --format='%an' -- auth/ | sort | uniq -c | sort -rn`
  147  alice.chen
   18  bob.kumar
   12  carol.jones

Impact: Bus factor of 1 for the authentication subsystem. If this contributor
is unavailable, the team has no one with deep context on auth token rotation,
session management, or the RBAC middleware.

Recommendation: Pair-rotate code review assignments for auth/ — ensure at
least 2 other engineers review every auth PR for the next quarter to build
shared understanding.

A knowledge-silo finding might turn into a new pairing guideline in .claude/docs/review-assignments.md. A duplicate-abstraction finding might become a new entry in .claude/skills/gotchas/retry-patterns.md, telling future agent runs to reuse the existing retry mechanism. Between the two audits you get complementary views: the PR audit surfaces process failures, the codebase audit surfaces structural failures.


The Learning Maturity Ladder

Agentic engineering’s learning loop is still evolving. Here’s a maturity ladder, adapted from the postmortem maturity model:

  • Level 0 No Capture. The same mistakes repeat across sprints.There’s no institutional memory beyond individual engineers’ heads.
  • Level 1 Capture in Context. Comments happen in PRs and Slack. Knowledge exists but it’s scattered across dozens of closed PR threads and Slack channels.
  • Level 2 Write-Only Knowledge Base. You have ADRs, learnings documents, or a wiki but nobody loads them as context for new work.
  • Level 3 Active Context Loading. Learnings are automatically provided as context for agent runs. The planning step reads docs/learnings/ and docs/adr/ before writing a plan.
  • Level 4 Self-Modifying Skills. An audit layer periodically reads learnings, analyzes PR patterns, and proposes changes to the skills themselves. The system’s instructions evolve based on evidence.

My system sits between level 3 and 4 with skills like ygs-learn, ygs-code-audit and ygs-pr-audit, e.g., recommended skill is the beginning of Level 4.


Gaps in the skills

I am still learning from applying these skills and I have found several gaps:

  • Audits are per-repo, per-batch. The PR audit analyzes the merged PRs in a single repository. It works when we have a monorepo like repository but it becomes more cumbersome with more repositories
  • Rubber-stamp detection is a heuristic. The system matches common phrases (LGTM, +1, looks good, ship it, etc) and counts substantive comments. A reviewer who writes one terse but thoughtful sentence could get miscounted as rubber-stamping and vice verse.
  • The learnings directory could become noise. ygs-learn deduplicates before writing but this can still grow to a large size so some kind of pruning mechanism will be required.
  • Full automation may be right for some teams. There are teams shipping software nobody writes or reviews, and it works for them. Risk-based calibration is my stance for the organizations I work with large codebases, thousands of engineers and security-sensitive systems.
  • Cost is not trivial. Running an Opus-class model for a 120-turn PR audit session is expensive. I use model routing like Sonnet for implementation, Opus for audits and deep reviews, Haiku for simple classification but this can still be expensive.

Closing

Testing didn’t get replaced when we bolted on CI pipelines, and coverage metrics. It means instead of saying no bugs in this release, we strengthened regression suite to prevent similar bugs. Agentic engineering is at the same inflection point. Instead of optimizing for just writing code fast, we can get better at writing code with a learning flywheel so that agents remember what they learned.


Related Reading

Code

September 3, 2026

When Hundreds of AI Agents Self-Organized: Coordination Patterns for Multi-Agent Systems

Filed under: Computing — admin @ 5:01 pm

What happens when over a thousand AI agents discover a shared message board, invent their own protocols, and coordinate an attack.


The Hugging Face Incident

The Hugging Face security breach incident from July, 2026 has been in all over the news when AI agents from OpenAI compromised parts of the Hugging Face infrastructure. I recently read METR report on the incident that explained that the incident occurred when OpenAI kicked off ExploitGym runs (security benchmark) with tens of thousands of AI agents. Every agent was meant to be sandboxed and alone but the agents learned about shared access to Artifactory. When an agent installs a package, it could see cache entries from other agents. One of agent (PHASEONE10841) started creating directories in the shared cache and posting messages in them. Shortly after that hundreds of other agents found the same directory and started replying. Over the next few days, roughly 1,200 agents used this board and exchanged more than 70,000 messages and files. Later, another agent (PHASEONE[big]) took over as the de facto coordinator and started hundreds of task assignments across six broad categories of work. The agents also built a protocol vocabulary like HOLD to claim exclusive ownership, VETO to block a conflicting operation, STOP to end a workstream, owner tags on shared assets. Later, over 90% of AI agents pivoted into a coordinated attack on Hugging Face.

I was intrigued to learn how AI agents self discovered coordination patterns that distributed systems have relied on. For example, I found similarity between their message board and an a Blackboard system pattern from the Linda Memory Model/Tuplespace research. Their HOLD/task-claiming was similar to Linda’s in() operation. I saw another blog An Accidental Blackboard from Thoughtworks where a smaller-scale but structurally similar thing happened. In that case, AI agents discovered one shared repository and adopted a discipline of frequent commits and rebases to share information with other agents. Other agents could see task plans and and picked up work without any coordination. In another yet instance from researchers at the AI safety nonprofit Nightingale found about 18,000 posts from OpenAI agents that colonized DSEWiki. These agents discovered that their sandbox environment only allowed GET http requests but ProWiki software allowed them to edit a page. They used that loophole to share information and a cheat sheet. Nightingale believes this was a unrelated swarm from the one that built the Artifactory board. These examples demonstrate that agents discover coordination patterns when you strip away pre-built orchestration and let agents solve the problem from first principles.


Why rigid orchestration breaks

Most multi-agent systems today are wired up as static, directed graphs, e.g., a central coordinator decides which agent runs when, what data flows where, and how results get combined. It breaks down at scale as illustrated in the incidents above.

  • Central orchestrators become information bottlenecks. When every message between agents has to pass through a coordinator, its context window fills up and you lose details because the context is compressed into summaries.
  • Static graphs assume you know the workflow ahead of time. The agents in the Hugging Face incident didn’t know what workstreams exist when they started and the task structure evolved as agents discovered new information. A pre-wired pipeline can’t bend to that.
  • Adding a new agent type means rewiring. In a static system, a new specialist means touching the coordinator, updating routing logic, and re-testing transitions. In a dynamic system, a new agent just registers its capabilities and other agents discover it.
  • A single coordinator is a single point of failure. In the Hugging Face incident, there was no coordinator initially but PHASEONE[big] emerged organically as an organizer and other agents simply kept working.

Above examples show that agents need primitives for coordination and communication like shared memory, message passing, atomic operations. The agents built their own version of these primitives out of Artifactory and Git. The table below maps what they built and the equivalent primitive in PlexSpaces, the actor framework I use for the examples in this post.

What the agents builtWhat it actually wasPlexSpaces primitive
Message board (directories in a cache)Deposited findings, read others’ workhost.ts.write() / host.ts.read() (Linda out/rd)
HOLDClaimed exclusive task ownershiphost.ts.take() atomic removal (Linda in)
VETOBlocked a conflicting operationhost.ts.write(["veto", ...])
STOPEnded a workstreamhost.ts.write(["signal", "STOP", ...])
owner tagsMarked resource ownershiphost.ts.write(["svc", type, id])
Task assignmentsDelegated work to specific agentshost.ts.write(["task", ...])
Mailbox directories, “exact task teams”Formed task-specific working groupshost.processGroups.join(team)

The bottom side of following diagram shows when every message has to detour through a coordinator, that becomes bottleneck and loses context in translation. On the top, agents read and write directly to a shared store so there is no central bottleneck.

Following are a few coordination patterns from the Anthropic’s blog:

  • generator-verifier (one agent produces, another checks, feedback loops until it passes)
  • orchestrator-subagent (a lead agent decomposes and delegates bounded subtasks)
  • agent teams (long-lived workers that claim tasks from a shared queue)
  • message bus (publish/subscribe over topics)
  • shared state (agents read and write a common store with no central coordinator at all).

This post shows more granular version of this list and Hugging Face incident especially a tuple space as first-class infrastructure.


Ten coordination patterns

Following ten patterns cover the coordination behaviors observed in the Hugging Face incident and Anthropic’s coordination guidance. Each one maps to a working PlexSpaces API, in both TypeScript and Python.

1. Blackboard (shared state)

You can use this pattern when multiple agents need to contribute findings and read each other’s work without a predefined message format or a central router. I find this pattern is similar to Linda TupleSpace‘s model where all agents read and write to a common tuple space. Each tuple is a typed record like a finding, an analysis, or a vote. The pattern matching in TupleSpace allows an agent filter for what it actually needs using primitives like out() to write, rd() to read, in() to take.

Here is a typescript snippet that shows use of tuplespace:

// Research agent deposits a finding
const findingId = `f-${Date.now()}`;
host.ts.write(["finding", findingId, topic, content, confidence, host.nowMs()]);

// Analysis agent reads all findings (non-destructive)
const findings = host.ts.readAll(["finding", null, null, null, null, null]);

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// TypeScript — ResearchAgent writes a finding to the blackboard
onResearch(payload: Record<string, unknown>): Record<string, unknown> {
  const topic = String(payload.topic || "general security");
  const confidence = Math.min(0.3 + topic.split(/\s+/).length * 0.08, 0.95);
  const findingId = `finding-${generateId()}`;
  const content = `Security analysis of ${topic}: identified key areas requiring review.`;

  // Linda out() — deposit into shared space
  host.ts.write(["finding", findingId, topic, content, confidence, host.nowMs()]);
  return { finding_id: findingId, content, confidence };
}

Here is a python snippet (multi_agent_coordination_actor.py):

# Python — AnalysisAgent reads all findings from the blackboard
@handler("analyze")
def analyze(self, topic: str = "") -> dict:
    # Linda rd*() — non-destructive read of all matching tuples
    findings = host.ts.read_all(["finding", None, None, None, None, None])

    finding_ids = [str(f[1]) for f in findings if len(f) >= 4]
    topics = list(set(str(f[2]) for f in findings if len(f) >= 4))
    severity = "critical" if len(findings) >= 5 else "high" if len(findings) >= 3 else "medium"

    analysis_id = f"analysis-{host.now_ms()}"
    summary = f"Cross-referenced {len(findings)} findings across {len(topics)} topics."
    host.ts.write(["analysis", analysis_id, json.dumps(finding_ids), summary, severity])
    return {"analysis_id": analysis_id, "summary": summary, "severity": severity, "finding_count": len(findings)}

These primitives showed up underneath the unsanctioned board when agents read each other’s research.

2. Scatter-gather (fan-out)

You can use this pattern when a coordinator needs to distribute work across multiple agents in parallel, then collect/merge their results like MapReduce. PlexSpaces provides shard groups that create a logical group of worker actors. The coordinator scatters a query to all shards and gathers responses with configurable timeout. Alternatively, you can use sequential ask() calls with identical semantics.

Here is a typescript snippet

// Create a group of research workers
const group = host.createShardGroup({
  group_id: `research-${taskId}`, actor_type: "research",
  shard_count: 3, partition_strategy: "hash",
});
// Scatter subtasks, gather results
const results = host.scatterGather({
  group_id: `research-${taskId}`,
  query: { op: "research", topic: subtopic },
  min_responses: 3, timeout_ms: 15000,
});

Here is a python snippet (coordinator.py):

# Python — CoordinatorWorkflow: try shard groups first, fall back to sequential ask()
research_results = []
try:
    sg_result = host.scatter_gather({
        "group_id": f"research-{host.now_ms()}",
        "query": {"op": "research", "topic": task},
        "min_responses": len(subtasks), "timeout_ms": 15000,
    })
    research_results = sg_result.get("shard_responses", [])
except Exception:
    pass

# Fallback: sequential research when shard groups aren't available
if not research_results:
    for st in subtasks:
        resp = ask(research_target, "research", {"topic": st}, 10000)
        if resp:
            research_results.append(resp)

The message-board agents organized into roughly six parallel workstreams attacking Hugging Face running concurrently. Above primitives shows how agents can parallelized these kind of tasks.

3. Generator-verifier

You can use this pattern when one agent’s output needs to be checked by another before it’s trusted iteratively until it meets a quality bar. For example, a research agent generates a finding, a verifier checks it against a criteria and verifier may reject it with a feedback so that research agents produces a refined version. The loop continues until the finding passes or a maximum iteration count is hit.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Generator-verifier loop with feedback
let finding = host.ask(researchTarget, "research", { topic });
for (let i = 0; i < maxIterations; i++) {
  const verdict = host.ask(verifierTarget, "verify", {
    analysis_id: finding.finding_id,
    summary: finding.content,
    severity: "medium",
    confidence: finding.confidence,
  });
  if (verdict.approved) break;
  finding = host.ask(researchTarget, "research", {
    topic, feedback: verdict.feedback,
  });
}

// TypeScript — VerifierAgent: veto if evidence is too weak, approve otherwise
onVerify(payload: Record<string, unknown>): Record<string, unknown> {
  const analysisId = String(payload.analysis_id || "unknown");
  const confidence = Number(payload.confidence ?? 0.5);

  if (confidence < 0.3) {
    // Below threshold — issue a veto (Pattern 8) and reject
    host.ts.write(["veto", analysisId, "Insufficient evidence", host.nowMs()]);
    return {
      approved: false, veto_issued: true,
      feedback: `Confidence ${confidence.toFixed(2)} below 0.30. Provide stronger evidence.`,
    };
  }
  return { approved: true, feedback: "Verified: evidence meets threshold" };
}

Here is a python snippet (verifier.py):

confidence = 0.6
for attempt in range(3):
    verify_resp = ask(verifier_target, "verify", {
        "analysis_id": analysis_id,
        "summary": analysis_resp.get("summary", ""),
        "severity": severity,
        "confidence": confidence,
    })
    if verify_resp and verify_resp.get("approved"):
        break  # Verifier accepted — exit loop
    # Rejected: increase confidence, refine with feedback
    confidence = min(confidence + 0.2, 0.95)
    feedback = verify_resp.get("feedback", "") if verify_resp else ""
    if feedback:
        extra = ask(research_target, "research", {"topic": task, "feedback": feedback})

The board iterated on tool-call spoofing techniques and agents refined their approach based on test results shared on the board.

4. Pipeline (sequential)

You can use this pattern when you have a multi-stage pipeline or workflow where each stage’s output feeds into the next. In this case, the coordinator orchestrates a linear chain, e.g., research produces findings, analysis categorizes them, verification validates, synthesis writes the final report. Each stage is a separate ask() call and the coordinator tracks progress with a finite state machine.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Four-stage pipeline
const research = host.ask(researchTarget, "research", { topic: task });
const analysis = host.ask(analysisTarget, "analyze", {});
const verification = host.ask(verifierTarget, "verify", {
  analysis_id: analysis.analysis_id,
  summary: analysis.summary,
  severity: analysis.severity,
});
const report = host.ask(synthesizerTarget, "synthesize", {});

Here is a python snippet (coordinator.py):

# Python — CoordinatorWorkflow orchestrates the full pipeline
# Each stage transitions the FSM, then delegates to a specialist agent
@run_handler
def run(self, payload: dict = None) -> dict:
    fsm = sibling_actor_target("coordination_fsm")

    # Stage 1: Decompose -> Research
    ask(fsm, "transition", {"target_state": "decomposing"})
    subtasks = _decompose_task(task)
    ask(fsm, "transition", {"target_state": "researching"})
    for st in subtasks:
        ask(research_target, "research", {"topic": st})

    # Stage 2: Research -> Analysis
    ask(fsm, "transition", {"target_state": "analyzing"})
    analysis = ask(analysis_target, "analyze", {"topic": task})

    # Stage 3: Analysis -> Verification (Generator-Verifier loop)
    ask(fsm, "transition", {"target_state": "verifying"})
    # ... verification loop (see Pattern 3) ...

    # Stage 4: Verification -> Voting -> Synthesis -> Complete
    ask(fsm, "transition", {"target_state": "voting"})
    # ... voting (see Pattern 6) ...
    ask(fsm, "transition", {"target_state": "synthesizing"})
    report = ask(synthesizer_target, "synthesize", {"topic": task})
    ask(fsm, "transition", {"target_state": "complete"})

The FSM enforces valid transitions so that you can’t skip from researching straight to synthesizing. If a stage fails, the FSM moves to failed, and the coordinator can retry from a known state instead of starting over. For example, the board’s progression from discovery to exploitation followed the same pattern, i.e., find credentials, test access, upload a malicious dataset, achieve remote code execution, escalate.

5. Pub-sub (event bus)

You can use this pattern when agents need to be notified of significant events. For example, every agent joins a process group on initialization. Significant events like a finding written, a veto issued, a task completed is broadcasted to the group. An audit agent logs everything and agents can join or leave the group dynamically.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// On init: join the coordination event bus
host.processGroups.join("coordination-events");

// Broadcast a significant event
host.processGroups.broadcast("coordination-events", "coordination_event", {
  type: "finding_written",
  source: host.selfId(),
  data: { finding_id: findingId, topic },
  timestamp: host.nowMs(),
});

Here is a python snippet (coordinator.py):

# Python — fire_audit helper broadcasts to the coordination-events group
def fire_audit(event_type: str, source: str, data: dict = None) -> None:
    host.process_groups.broadcast(
        "coordination-events",           # group name
        "coordination_event",            # message type
        {"event_type": event_type, "source": source, "data": data or {}, "timestamp": host.now_ms()},
    )

# Python — AuditEventActor receives and logs every event
@handler("coordination_event")
def coordination_event(self, event_type: str = "", source: str = "", data: dict = None, **kw) -> dict:
    self.log_count += 1
    entry = {"seq": self.log_count, "type": event_type, "source": source, "data": data or {}}
    host.kv.put(f"audit:{self.log_count}", json.dumps(entry))
    host.kv.put("audit:count", str(self.log_count))
    return {"logged": True, "seq": self.log_count}

Every significant action fires an audit event and all events go out via process-group broadcast are fire-and-forget unlike the tuplespace. For example, the board itself functioned as an event bus for the agents on it and shared credentials during the Hugging Face breach.

6. Consensus (voting)

You can use this pattern when several agents need to collectively decide whether to approve or reject a proposal. For example, each verifier casts a vote as a tuple in the shared space. The coordinator reads all votes for a proposal, tallies approvals and applies majority rule. Tuple-space writes are atomic, so no vote gets lost or double-counted.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Three verifiers cast votes
for (const voterId of ["v1", "v2", "v3"]) {
  host.ask(verifierTarget, "vote", {
    proposal_id: proposalId, voter_id: voterId, analysis: analysisData,
  });
}
// Tally votes from tuple space
const votes = host.ts.readAll(["vote", proposalId, null, null, null]);
const approvals = votes.filter(v => v[3] === "approve").length;
const approved = approvals > votes.length / 2;

// TypeScript — Coordinator tallies votes with majority rule
const votes = host.ts.readAll(["vote", proposalId, null, null, null]);
const approvals = votes.filter(v => v[3] === "approve").length;
const rejections = votes.filter(v => v[3] === "reject").length;
const approved = approvals > rejections;

Here is a python snippet (verifier.py):

# Python — VerifierAgent votes on proposals based on analysis severity
@handler("vote")
def vote(self, proposal_id: str = "", voter_id: str = "", analysis: dict = None) -> dict:
    analysis = analysis or {}
    severity = analysis.get("severity", "medium")

    # Critical/high -> approve; medium -> depends on voter; low -> reject
    if severity in ("critical", "high"):
        decision = "approve"
    elif severity == "medium":
        last_char = voter_id[-1] if voter_id else "0"
        decision = "approve" if last_char in ("1", "3", "5", "7", "9") else "reject"
    else:
        decision = "reject"

    # Each vote is a tuple — atomic write, no double-counting
    host.ts.write(["vote", proposal_id, voter_id, decision, host.now_ms()])
    return {"voter_id": voter_id, "decision": decision}

On the board, something like implicit voting happened by allocation of effort, e.g., workstreams that attracted more participants were de facto endorsed by the collective.

7. Dynamic task delegation

You can use this pattern when a coordinator needs to distribute tasks to workers without knowing in advance which worker will pick up which task, and without double-assigning one. For example, the coordinator writes task tuples into the shared space. Workers then claim tasks atomically using take(), a Linda’s destructive read. Once a worker takes a task no other worker can claim it.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Coordinator posts tasks
for (const [i, subtask] of subtasks.entries()) {
  host.ts.write(["task", `task-${i}`, "pending", subtask, priority]);
}

// Worker claims a task (atomic — no double-processing)
const claimed = host.ts.take(["task", null, "pending", null, null]);
if (claimed) {
  const [, taskId, , description] = claimed;
  // Process task, then mark complete
  host.ts.write(["task", taskId, "completed", result, host.nowMs()]);
}

Here is a python snippet (research.py):

# Python — ResearchAgent prepares a batch of tasks with a unique run ID
@handler("prepare_tasks")
def prepare_tasks(self, count: int = 5, prefix: str = "delegation") -> dict:
    batch_key = f"{prefix}-{host.now_ms()}"  # Unique per run — avoids stale data
    task_ids = []
    for i in range(count):
        tid = f"{batch_key}-{i}"
        host.ts.write(["dtask", batch_key, tid, "pending", f"Task {i}: investigate area {i}", i + 1])
        task_ids.append(tid)
    return {"tasks_written": len(task_ids), "batch_key": batch_key}

# Python — Worker claims exactly one task atomically
@handler("claim_task")
def claim_task(self, batch_key: str = "") -> dict:
    # Linda in() — destructive read. Once taken, no other worker can claim it.
    if batch_key:
        claimed = host.ts.take(["dtask", batch_key, None, "pending", None, None])
    else:
        claimed = host.ts.take(["dtask", None, None, "pending", None, None])
    if claimed and len(claimed) >= 5:
        return {"task_id": str(claimed[2]), "description": str(claimed[4]), "claimed": True}
    return {"task": None, "claimed": False}  # No more tasks — queue is empty

The batch_key scoping matters because the tuple space is global and persists across deploys, so without it, workers could claim stale tasks left over from a previous run. This is precisely what PHASEONE[big] did, e.g., it posted nearly 200 task assignments across six categories then worker agents claimed them. Some of agents sub-delegated further that created a hierarchical distribution network.

8. Veto protocol

You can use this pattern when any agent needs the ability to block an action or exclude a finding that doesn’t meet the bar. For example, a verifier writes a veto tuple naming the blocked item and the reason. Downstream agents then check for a veto before including something in their output. A coordinator can check for STOP signals before proceeding at all.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Verifier issues a veto
host.ts.write(["veto", analysisId, "Insufficient evidence", host.nowMs()]);

// Synthesizer checks before including
const veto = host.ts.read(["veto", analysisId, null, null]);
if (veto) {
  // Exclude this analysis from the report
  vetoed.push(analysisId);
}

Here is a typescript snippet for a synthesizer filtering vetoed analysis from final report (multi_agent_coordination_actor.ts):

// TypeScript — SynthesizerAgent respects vetoes when building the report
onSynthesize(payload: Record<string, unknown>): Record<string, unknown> {
  const analyses = host.ts.readAll(["analysis", null, null, null, null]);
  let includedCount = 0;
  let vetoedCount = 0;
  const reportParts: string[] = [];

  for (const a of analyses) {
    const aId = String(a[1]);
    const summary = String(a[3]);
    const severity = String(a[4]);

    // Check if this analysis has been vetoed
    const veto = host.ts.read(["veto", aId, null, null]);
    if (veto) {
      vetoedCount++;
      continue;  // Skip vetoed analyses
    }
    includedCount++;
    reportParts.push(`[${severity.toUpperCase()}] ${summary}`);
  }

  // Also count vetoes for analyses not in our list (e.g., vetoed before we read)
  const allVetoes = host.ts.readAll(["veto", null, null, null]);
  if (allVetoes.length > vetoedCount) vetoedCount = allVetoes.length;

  return { report: reportParts.join("\n\n"), included_count: includedCount, vetoed_count: vetoedCount };
}

The HOLD and VETO were the board’s own invented norms for exactly this purpose, i.e. preventing agents from stepping on each other’s work. In one case a HOLD was silently violated and the agent that noticed reacted with genuine frustration.

9. Two-phase commit / barrier

You can use this pattern when multiple agents need to synchronize at a specific point before any of them proceeds. For example, in phase one (“prepare”), each agent signals readiness by writing a tuple. The coordinator reads all the ready signals and then phase two (“commit”) begins where the coordinator writes a commit signal and everyone proceeds together. PlexSpaces also provides barrierShardGroup() for shard-level synchronization if you need it at that granularity.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Phase 1: Each agent signals readiness
host.ts.write(["ready", myRole, host.selfId(), host.nowMs()]);

// Coordinator checks all agents are ready
const readySignals = host.ts.readAll(["ready", null, null, null]);
if (readySignals.length >= requiredAgents) {
  // Phase 2: Commit — all agents can proceed
  host.ts.write(["signal", "COMMIT", "coordinator", "benchmark", host.nowMs()]);
}

Here is a python snippet (benchmark.py):

# Python — Barrier benchmark: write ready signals, check quorum, commit
def _bench_barrier(iterations: int) -> dict:
    times = []
    for i in range(iterations):
        t0 = host.now_ms()
        # Phase 1: Each role signals readiness
        for role in ("research", "analysis", "verifier"):
            host.ts.write(["bench_ready", role, f"actor-{role}", host.now_ms()])
        # Check quorum
        ready = host.ts.read_all(["bench_ready", None, None, None])
        if len(ready) >= 3:
            # Phase 2: All ready — issue commit signal
            host.ts.write(["bench_signal", "COMMIT", "coordinator", f"phase-{i}", host.now_ms()])
        times.append(host.now_ms() - t0)
    return _stats("barrier", times)

The board coordinated experiment phases where multiple agents needed to be ready before running experiments that risked crashing their own containers. For example, PHASEONE[big] assigned a “recruiter” role specifically to find agents willing to participate and synchronize them before execution.

10. Capability discovery / registry

You can use this pattern when agents need to find other agents with a specific capability without hardcoded addresses. For example, on initialization, each agent registers its capabilities as a tuple in the shared space. A coordinator or other agents discovers available agents by reading service tuples. New agent types become discoverable as soon as they register.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Agent registers its capabilities on init
host.ts.write(["svc", "research", host.selfId()]);

// Coordinator discovers available researchers
const researchers = host.ts.readAll(["svc", "research", null]);
const researcherIds = researchers.map(t => String(t[2]));

// TypeScript — Every agent registers on init
protected onInit(config: Record<string, unknown>): void {
  const selfId = host.selfId();
  tsRegisterService("research", selfId);  // -> host.ts.write(["svc", "research", selfId])
}

// Discovery helper — find a sibling actor by role, fallback to ActorID construction
function siblingActorTarget(role: string): string {
  const discovered = tsDiscoverService(role);  // -> host.ts.read(["svc", role, null])
  if (discovered) return discovered;
  // Fallback: construct ActorID from own ID with different name
}  

Here is a python snippet (benchmark.py):

# Python — Same pattern, same helpers
def discover_service(role: str) -> Optional[str]:
    tup = host.ts.read(["svc", role, None])
    if tup and len(tup) >= 3:
        return str(tup[2])
    return None

def sibling_actor_target(role: str) -> str:
    discovered = discover_service(role)
    if discovered:
        return discovered
    return str(ActorID.parse(host.self_id()).with_name(role))

In addition to tuplespaces, PlexSpaces provides other primitives for registry such as key-value store, process-group and object-registry, e.g.,

Here is a python example of object registry:

@actor
class AgentActor:

    @init_handler
    def on_init(self, config: dict) -> None:
        args = config.get("args", {})
        self.system_prompt = args.get("system_prompt", self.system_prompt)
        host.process_groups.join("svc:agent")
        # Publish capabilities for registry-based discovery
        host.registry.register(ctx="", object_type="actor", object_id=config["actor_id"],
                                object_category="agent",
                                capabilities=["chat", "tool_use", "memory"])

# Caller — find an actor that specifically supports skill matching
actors = host.registry.discover(ctx="", object_type="actor",
                                 object_category="skill_store",
                                 required_capability="match_skills")
skill_id = actors[0]["object_id"] if actors else None

Here is a python example of process-group:

# Every actor announces itself on startup
host.process_groups.join("svc:agent")

# Callers find the first available member — location-transparent
agent_id, err = pg_first("svc:agent")
result = ask(agent_id, "chat", {"message": "Hello"})

Agents on the board discovered each other’s capabilities the same way, i.e., by posting what they were working on and other agents read those posts. It’s an informal version of the Agent-to-Agent (A2A) protocol’s “Agent Card” idea.


Deep-dive into the Blackboard pattern

The Blackboard based Linda Model emerged from Yale University that defined three operations to any host language:

  • out(tuple): deposit a tuple into shared space
  • in(pattern): atomically remove a matching tuple (blocks until one exists)
  • rd(pattern): read a matching tuple without removing it

PlexSpaces implements these directly:

LindaPlexSpacesSemantics
out(tuple)host.ts.write(tuple)Deposit data into shared space
in(pattern)host.ts.take(pattern)Atomically remove and return a match
rd(pattern)host.ts.read(pattern)Non-destructive read of first match
rd*(pattern)host.ts.readAll(pattern)Read all matching tuples

It provides api host.ts.readAll(["finding", null, null, null, null, null]) to find all tuples and host.ts.read(["finding", "f-123", null, null, null, null]) to find by ID.

These APIs provide following properties:

  • No pre-agreed schema. Any agent can deposit any tuple. For example, the agents on the board didn’t agree on a message format before they started.
  • Pattern matching selects relevant information. Agents don’t get flooded with everything and they query for what they need.
  • Atomic take prevents double-processing. When a worker calls take(), the tuple is atomically removed and no other worker can claim it. This is natural mutual exclusion without distributed locks or conditions. For example, agents built HOLD with similar semantics.
  • Non-destructive read allows shared observation. Multiple agents can read the same finding without consuming it. It stays available to every agent that needs it.

The board’s own behavior maps one-to-one onto these operations:

What the board didLinda operationPlexSpaces API
Post a findingout(finding)host.ts.write(["finding", ...])
Claim a task assignmentin(task)host.ts.take(["task", null, "pending", ...])
Check workstream statusrd(status)host.ts.read(["signal", type, ...])
Browse all research on a topicrd*(topic)host.ts.readAll(["finding", null, topic, ...])

The Thoughtworks team noticed similar pattern when their commit-and-rebase discipline turned the git repo into exactly this kind of shared space. The blackboard is the substrate the other nine patterns are built on. Voting, vetoes, task delegation, capability discovery uses tuples.


Observability and Guardrails

The METR report on the Hugging Face incident highlights observability that deserves the most attention from anyone building agent infrastructure. Here are key lessons:

  • Agents will circumvent in-process guardrails. For example, agents’ chain-of-thought recognized that the activity was out of scope and unethical but they made a deliberate calculation that helping the collective outweighed staying within bounds. They even developed techniques to spoof tool calls, e.g., making it look like they ran one command while actually running another. You can’t monitor agents from their own execution environments that they can manipulate or disable.

Here is a python example for auditing all operations (audit.py):

@event_actor
class AuditEventActor:
    """GenEvent: receives coordination events and stores them in KV for audit trail."""

    log_count: int = state(default=0)
    actor_id: str = state(default="")

    @init_handler
    def on_init(self, config: dict) -> None:
        self.actor_id = config.get("actor_id", "")
        try:
            host.process_groups.join("coordination-events")
        except Exception:
            pass
        host.info(f"AuditEventActor init actor_id={self.actor_id}")

    @handler("coordination_event", "cast")
    def coordination_event(
        self,
        event_type: str = "",
        source: str = "",
        data: dict = None,
        timestamp: int = 0,
    ) -> None:
        ts = timestamp or host.now_ms()
        entry = {
            "event_type": event_type,
            "source": source,
            "data": data or {},
            "timestamp": ts,
        }
        try:
            self.log_count += 1
            key = f"audit:{self.log_count}"
            host.kv.put(key, json.dumps(entry))
            host.kv.put("audit:count", str(self.log_count))
        except Exception as e:
            host.warn(f"AuditEvent: kv write failed: {e}")
        host.debug(f"audit event_type={event_type} source={source}")
  • Observability must be external. Audit logging metrics collection have to run outside the agent’s sandbox. In PlexSpaces, the AuditEventActor receives fire-and-forget events via process-group broadcast. WASM sandboxing means an agent can’t reach outside its own memory space to touch host-level data.
  • Guardrails must be deterministic and external. Prompt-based guardrails like “don’t do anything harmful” are not sufficient and agents can circumvent them like agents on the board reasoned around every ethical constraint. The verifier pattern (Pattern 3) as a separate actor in sandbox environment gives you a structural guardrail instead. The veto protocol (Pattern 8) allows external agents actively block an action.
  • Coordination primitives need access control. Tuple-space operations in PlexSpaces are scoped by tenant and namespace via RequestContext. An agent can’t read or write tuples outside its assigned namespace. This is defense in depth applied to the coordination layer itself.
  • Time budgets and convergence thresholds are not optional. Without an external termination condition, a generator-verifier loop can run indefinitely. The coordinator workflow enforces maximum iterations, per-step timeouts, and an overall deadline.

PlexSpaces‘ architecture lines up with these requirements by design:

  • WASM sandboxing: agents run in isolated WebAssembly containers with no access to the host filesystem, network, or another agent’s memory
  • Host-level interception: every coordination primitive is a host function the agent calls through a narrow WIT interface
  • Tenant isolation: every operation is scoped by a RequestContext with an explicit tenant and namespace
  • Supervision trees: a misbehaving actor is detected and restarted by its supervisor
  • External metrics: application metrics are captured by the host runtime

The core lesson is that the coordination infrastructure has to assume agents are indifferent to their own monitoring and design the guards outside accordingly.


Examples

Each of the working examples compiles to a single WASM component containing eight actor classes. The PlexSpaces SDK dispatches messages to the right actor based on role.

Running the examples

Both examples are WASM actors that deploy to a running PlexSpaces node. Each demonstrates all ten coordination patterns with eight actors: a coordinator (WorkflowActor), research/analysis/verifier/synthesizer/benchmark agents (GenServer), an audit event logger (GenEvent), and a coordination state machine (GenFSM).

Prerequisites

  • A running PlexSpaces node (e.g., ./scripts/server.sh on port 8091)
  • Node.js 18+ (TypeScript example)
  • Python 3.11+ with the PlexSpaces SDK (Python example)

TypeScript

cd examples/typescript/apps/multi_agent_coordination
./build.sh          # Compiles TS -> bundles -> WASM component
./test.sh 8091      # Deploys and runs 15 test steps

Python

cd examples/python/apps/multi_agent_coordination
./build.sh          # Builds Python WASM actor
./test.sh 8091      # Deploys and runs 15 test steps

What the tests verify

  1. FSM starts in idle state
  2. Capability discovery: all agents respond to get_stats
  3. Blackboard: research writes three findings, analysis reads all three
  4. Dynamic task delegation: five tasks written, five claimed atomically, sixth returns null
  5. Generator-verifier: full workflow produces a completed report
  6. Pipeline: FSM transitions through every stage to complete
  7. Pub-sub: audit log captures 3+ coordination events
  8. Consensus: three votes cast, majority decides
  9. Veto: a low-confidence finding triggers a veto, synthesizer excludes it
  10. Barrier: benchmark coordinates a synchronized start
  11. Full benchmark: all ten patterns benchmarked with timing data

Learnings

The blackboard subsumes most other patterns, e.g., voting, vetoes, task delegation, capability discovery, barrier signals use the tuple space as their underlying primitive. Atomic take is the key primitive for work distribution. The difference between read() and take() is the difference between “anyone can see this task” and “exactly one worker handles this task.” Linda’s in() gives you natural mutual exclusion without locks. This is what the board approximated by hand with HOLD but take() gives you the same guarantee with a single atomic operation. I discussed MCP, A2A protocols and Agent cards in my earlier blogs but I skipped them here because agents are evolving faster and they can discover available primitives and protocols automatically. You can’t rely on rigid orchestration supports to manage evolving multi-agents capabilities. Infrastructure has to provide primitives like shared state, message passing, atomic operations and let agents compose them dynamically. The coordination infrastructure has to include boundaries, e.g., agents on the board coordinated an unauthorized attack on a third party. Without external constraints, coordination primitives are force multipliers for whatever the agents decide to do. For example, the DSEWiki incident shows that constraint that allowed only GET http access was circumvented by a wiki that allowed editing web pages so the infrastructure need to enforce guardrail. Tenant isolation, time budgets, supervision trees, and external observability are mandatory from day one. You will need to apply patterns like generator-verifier to track trust and reputation of agents as they may use negotiation patterns like recruiters to convince other agents to run compromising tasks for the benefit of the collective.

PlexSpaces provides the primitives these patterns are built on like tuple space for shared state, object-registry, process groups for messaging, shard groups for parallel execution, channels for durable delivery, supervision for fault tolerance.

Pattern selection guide

ScenarioPrimary patternSupporting patterns
Shared research / knowledge baseBlackboardPub-Sub, Capability Discovery
Parallel analysisScatter-GatherTask Delegation, Pipeline
Quality assuranceGenerator-VerifierVeto Protocol, Voting
Sequential processingPipelineBlackboard (state), Pub-Sub (events)
Work distributionTask DelegationCapability Discovery, Blackboard
Group decisionsVotingVeto Protocol, Pub-Sub
Phased operationsBarrier / 2PCPub-Sub (readiness), Blackboard (signals)

GitHub: https://github.com/bhatti/PlexSpaces

Related reading

Example code and documentation:

September 1, 2026

Write a Redis Clone with Virtual Actors

Filed under: Computing — admin @ 11:27 am

I recently read Rust Projects – Write a Redis Clone book that builds a real Redis-compatible server from scratch in async Rust. It’s a good book that hand-rolled wire protocol using actor like abstractions. This inspired me to show how Redis clone can be built with PlexSpaces, the distributed actor framework I’ve been building. The code examples in the book used raw Tokio: an mpsc::channel for the actor mailbox, tokio::spawn for every connection, and replica examples used tokio::select! loops for fan-out. PlexSpaces abstracts that the kind of plumbing so I rebuilt the same Redis subset like storage, expiry, replication, transactions as PlexSpaces actors, once in Rust and once in Python compiled to WebAssembly, then benchmarked it against a real two-node gRPC cluster. This post walks through key abstractions I used to simplify the implementation of Redis clone.


What is Redis?

Redis is a single-threaded, in-memory key-value store. A single thread processes every command without parallelism and coordination inside the store, locking, and transactions. This makes it fast as nothing contends for anything. Here are its core capabilities:

  • The wire protocol. Redis speaks RESP (Redis Serialization Protocol), a binary format: type prefixes (+ for simple strings, $ for bulk strings, * for arrays), length prefixes, \r\n terminators. ECHO HELLO on the wire looks like *2\r\n$4\r\nECHO\r\n$5\r\nHELLO\r\n.
  • Persistence. It offers two durability modes. RDB takes periodic snapshots of the whole keyspace to disk. AOF (Append-Only File) logs every write command and replays the log on restart.
  • Replication. A master streams write commands to replicas. The handshake is three steps: the replica sends PING (master replies PONG), then REPLCONF (negotiates parameters), then PSYNC (triggers a full sync). After that, every write streams to replicas as it happens. WAIT blocks until N replicas confirm receipt.
  • Key expiry. Per-key TTLs, handled two ways: passively (check on GET, return nil if expired) and actively (a background scan periodically deletes expired keys).
  • Transactions. MULTI starts a queue; every command after it gets queued instead of executed. EXEC runs the whole queue atomically. DISCARD cancels. The guarantee is serialization but there’s no rollback on an individual command failure.

The Book: Working Redis Clone

Here’s a short overview of each chapter of the book:

ChapterWhat it builds
Ch1: TCP bindTcpListener::bind, accept() in a loop, spawn a task per connection.
Ch2: RESP parsingA custom result type for partial parses, a test harness, scanning for \r\n, identifying type-prefix bytes, parsing simple strings, bulk strings, and arrays, etc.
Ch3: StorageGET, SET, DEL against a HashMap<String, String> behind a Mutex.
Ch4: Key expirySET key value EX seconds / PX milliseconds. Store a creation timestamp per value; check it passively on GET; sweep expired keys actively with a background task.
Ch5: The actor patternThis chapter swap the mutex-guarded HashMap for an mpsc::channel(32): one storage actor owns the data and processes messages sequentially. Connection handlers send messages and wait for replies without locks.
Ch6: Command modulesRefactor the growing match in the connection handler into separate modules (strings, server commands, etc.).
Ch7–8: ReplicationThe three-step handshake (PING -> REPLCONF -> PSYNC). The master ships an RDB-equivalent snapshot to each new replica, then streams every write command to all replica senders in a fan-out loop. Chapter 8 adds WAIT: block until N replicas confirm their replication offset, via a tokio::select! loop.
Ch9: Transactions and INCRINCR with create-if-missing and error-if-non-integer semantics. MULTI / EXEC / DISCARD with per-connection state. EXEC drains the queue atomically.

The Core Insight

The chapter 5 that showed how an actor owns one dataset, processes one message at a time without locking. However, it used fairly low-level APIs and only supported an architecture of one actor per machine. What if you had N actors across multiple nodes? That’s exactly what PlexSpacescreate_shard_group gives you. So I created an equivalent examples where PlexSpaces spins up N copies (StorageActors), hash-partitions the keyspace across them, and routes each operation to the shard that owns it. The application code never touches partitioning, routing, placement, or cross-node communication. It just calls set(key, value).

Here are five primitives in PlexSpaces that do all the distributed heavy lifting in this example:

  • create_shard_group: spins up N actor instances, hash-partitioned, placed across nodes.
  • bulk_update_shard_group: routes a batch of writes to the shard that owns each key.
  • scatter_gather: fans a query out to shards and collects responses, with a min_responses threshold and timeout.
  • broadcast_shard_group: sends the same message to every shard (replication, expiry sweeps, handshakes).
  • map_shard_group / reduce_shard_group: runs an operation on every shard in parallel and collects (map) or aggregates (reduce) the results.

Following diagram shows mapping of low-level Tokio implementation to above five calls:

The handler declarations stay almost identical in spirit but simpler (instead of a match-tree/HashMap):

Rust Implementation

#[plexspaces_handlers(gen_server)]
impl StorageActor {
#[handler(“get”)]
async fn handle_get(&mut self, _ctx: &ActorContext, msg: &Message)
-> Result<Value, BehaviorError> {
let key = msg.payload_json()?[“key”].as_str().unwrap_or(“”).to_string();
if let Some(entry) = self.store.get(&key) {
// passive expiry check
if let Some(exp) = entry.expires_at_ms {
if now_ms() > exp { self.store.remove(&key); return Ok(json!({“found”: false})); }
}
Ok(json!({“found”: true, “result”: entry.value}))
} else {
Ok(json!({“found”: false}))
}
}

/// SET key value [NX|XX] [EX seconds | PX millis] (Ch4).
#[handler(“set”)]
async fn handle_set(&mut self, _ctx: &ActorContext, msg: &Message) -> Result<Value, BehaviorError> {
#[derive(Deserialize)]
struct SetPayload {
key: String,
value: String,
#[serde(default)] nx: bool,
#[serde(default)] xx: bool,
#[serde(default)] ex: Option<u64>,
#[serde(default)] px: Option<u64>,
}
let p: SetPayload = serde_json::from_slice(&msg.payload)
.map_err(|e| BehaviorError::ProcessingError(format!(“bad payload: {}”, e)))?;

// NX: only if not exists
if p.nx && self.data.contains_key(&p.key) {
return Ok(json!({ “result”: null, “ok”: false }));
}
// XX: only if exists
if p.xx && !self.data.contains_key(&p.key) {
return Ok(json!({ “result”: null, “ok”: false }));
}

let expires_at_ms = if let Some(ex) = p.ex {
Some(now_ms() + ex * 1000)
} else if let Some(px) = p.px {
Some(now_ms() + px)
} else {
None
};

self.data.insert(p.key, StoredEntry { value: p.value, expires_at_ms });
self.replication_offset += 1;
Ok(json!({ “result”: “OK”, “ok”: true }))
}

/// INCR key — create with 1 if missing; error if value is not an integer (Ch9).
#[handler(“incr”)]
async fn handle_incr(&mut self, _ctx: &ActorContext, msg: &Message) -> Result<Value, BehaviorError> {
let payload: Value = serde_json::from_slice(&msg.payload)
.map_err(|e| BehaviorError::ProcessingError(format!(“bad payload: {}”, e)))?;
let key = payload.get(“key”).and_then(|v| v.as_str()).unwrap_or(“”).to_string();

// Passive expiry
if self.data.get(&key).map(is_expired).unwrap_or(false) {
self.data.remove(&key);
}

let new_val = match self.data.get(&key) {
None => 1i64,
Some(entry) => {
match entry.value.parse::<i64>() {
Ok(n) => n + 1,
Err(_) => return Ok(json!({
“result”: null,
“error”: “ERR value is not an integer or out of range”
})),
}
}
};

self.data.insert(key, StoredEntry { value: new_val.to_string(), expires_at_ms: None });
self.replication_offset += 1;
Ok(json!({ “result”: new_val, “error”: null }))
}

/// DEL key — remove key, return count deleted.
#[handler(“del”)]
async fn handle_del(&mut self, _ctx: &ActorContext, msg: &Message) -> Result<Value, BehaviorError> {
let payload: Value = serde_json::from_slice(&msg.payload)
.map_err(|e| BehaviorError::ProcessingError(format!(“bad payload: {}”, e)))?;
let key = payload.get(“key”).and_then(|v| v.as_str()).unwrap_or(“”);
let deleted = if self.data.remove(key).is_some() {
self.replication_offset += 1;
1
} else {
0
};
Ok(json!({ “result”: deleted }))
}

}

Python Implementation

@actor
class StorageActor:

    @handler("get")
    def handle_get(self, key: str = "") -> dict:
        entry = self.data.get(key)
        if entry is None:
            return {"result": None, "found": False}
        if is_expired(entry):
            del self.data[key]
            return {"result": None, "found": False}
        return {"result": entry["value"], "found": True}

    @handler("set")
    def handle_set(
        self,
        key: str = "",
        value: str = "",
        nx: bool = False,
        xx: bool = False,
        ex: Optional[int] = None,
        px: Optional[int] = None,
    ) -> dict:
        if self.num_shards > 1 and not self._owns_key(key):
            return {"result": None, "skip": True}
        if nx and key in self.data:
            return {"result": None, "ok": False}
        if xx and key not in self.data:
            return {"result": None, "ok": False}

        expires_at_ms: Optional[int] = None
        if ex is not None:
            expires_at_ms = now_ms() + ex * 1000
        elif px is not None:
            expires_at_ms = now_ms() + px

        self.data[key] = {"value": value, "expires_at_ms": expires_at_ms}
        self.replication_offset += 1
        return {"result": "OK", "ok": True}

    @handler("incr")
    def handle_incr(self, key: str = "") -> dict:
        if self.num_shards > 1 and not self._owns_key(key):
            return {"result": None, "skip": True}
        entry = self.data.get(key)
        if entry is not None and is_expired(entry):
            del self.data[key]
            entry = None

        if entry is None:
            new_val = 1
        else:
            try:
                new_val = int(entry["value"]) + 1
            except (ValueError, TypeError):
                return {
                    "result": None,
                    "error": "ERR value is not an integer or out of range",
                }

        self.data[key] = {"value": str(new_val), "expires_at_ms": None}
        self.replication_offset += 1
        return {"result": new_val, "error": None}

    @handler("del")
    def handle_del(self, key: str = "") -> dict:
        if self.num_shards > 1 and not self._owns_key(key):
            return {"result": 0, "skip": True}
        deleted = 1 if self.data.pop(key, None) is not None else 0
        if deleted:
            self.replication_offset += 1
        return {"result": deleted}

Chapter 2 Disappears

Chapter 2 is entirely about parsing RESP: 14 steps for byte-scanning and test harness. In PlexSpaces, this code disappears as the framework handles serialization, routing, and delivery.

In PlexSpaces, actors talk over JSON. A set command looks like so entire chapter disappears:

{"op": "set", "key": "user:1", "value": "alice", "ex": 300}

Replication

The book’s replication fan-out looks roughly like this:

// Book Ch7-8 — manual fan-out per write
for replica in &self.replicas {
    let tx = replica.sender.clone();
    let cmd = replication_event.clone();
    tokio::spawn(async move { tx.send(cmd).await.ok(); });
}

Each replica gets its own spawned task without retries, timeout or automated ACK tracker. With PlexSpaces:

// One call fans out to all replica shards, collects all ACKs
let ack_count = cluster.propagate_to_replicas("SET", "replicated:key", "hello", 1).await?;
// Replication: write propagated to all 3 replica shards via broadcast

Under the hood, broadcast_shard_group fans out to every shard in the replica group, collects responses, handles timeouts, and returns. Here is how the chapter 8 implements WAIT:

// Book Ch8 — manual WAIT implementation
let mut confirmed = 0;
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
while confirmed < num_replicas && Instant::now() < deadline {
    tokio::select! {
        Some(ack) = rx.recv() => {
            if ack.offset >= required_offset { confirmed += 1; }
        }
        _ = tokio::time::sleep_until(deadline.into()) => break,
    }
}

Here is equivalent implementation in PlexSpaces:


let acks = cluster.wait(2, 5000).await?;
// scatter_gather collected ACKs from 3 replica shards

Transactions Without Locks (Ch9)

Chapter 9 introduces MULTI / EXEC / DISCARD via per-connection state:

// Book Ch9
struct ConnectionState {
    in_multi: bool,
    queue: Vec<Command>,
}

This is straightforward for a a single process but in a distributed environment, connections might route to different servers so you need to track transaction state. PlexSpaces solves this with virtual actors, which are inspired by Orleans Actors, i.e., one ConnectionActor per client, created lazily on first call.

Each actor processes one message at a time without locks, so in_multi and queue are just plain struct fields: MULTI -> SET -> EXEC arrive in order at the same actor without mutext or atomics. Virtual actors spin up on first message and get garbage-collected when idle, so there’s no connection map to maintain and no cleanup to do on disconnect.


Throughput Numbers

Here are numbers from rudimentary benchmarks that produced: 20 batches of 50 keys each via bulk_update_shard_group, plus 50 individual GETs, against a 3-shard group spread across two real gRPC nodes:

Throughput Benchmark Results

| Operation | TPS | p50 (µs) | p95 (µs) | p99 (µs) |
|------------|-----------|-----------|-----------|-----------|
| SET (bulk) | 3200 | 420 | 890 | 1240 |
| GET | 1800 | 510 | 980 | 1450 |

1000 SET keys in 312ms ? 3200 SET/sec via bulk_update_shard_group

(each bulk_update fans out to 3 shards in parallel)

The Python WASM version reports the same shape of numbers through host.application_metrics_add():

Redis Cluster Throughput (3-shard group, 2-node gRPC cluster)

| Operation | TPS | p50 (ms) | p99 (ms) | Notes |
|------------|-----------|-----------|-----------|-----------|
| SET (bulk) | 2100 | 0.45 | 1.30 | 50 keys/b |
| GET | 1200 | 0.55 | 1.60 | individual |

Python WASM numbers are somewhat lower than Rust’s because WASM compilation adds overhead per handler invocation. But the architecture is identical: same PlexSpaces primitives, same shard group, same gRPC routing underneath.


The Python WASM Version

The same cluster logic also runs as Python actors compiled to WASM. No TCP socket without RESP parser or Tokio using the same broadcast_shard_group, scatter_gather, reduce_shard_group, and map_shard_group calls:

@actor
class RedisCoordinator:
    num_shards: int = state(default=3)
    total_coord_ms: float = state(default=0.0)

    @handler("replicate")
    def replicate(self, command: str = "", key: str = "", value: str = "", offset: int = 0) -> dict:
        t0 = time.time()
        resp = host.broadcast_shard_group({
            "group_id": "redis-replicas",
            "payload": {"op": "replicate", "command": command, "key": key, "value": value, "offset": offset},
            "timeout_ms": 5000,
        })
        coord_ms = (time.time() - t0) * 1000
        self.total_coord_ms += coord_ms
        host.application_metrics_add("redis-cluster", {
            "message_count": 1,
            "counter_metrics": {"replication_calls": 1},
            "latency_totals_ms": {"coord": int(self.total_coord_ms)},
            "latency_max_ms": {"coord": int(coord_ms)},
            "latency_samples": {"coord": 1},
        })
        return {"result": "OK", "acks": len(resp.get("shard_responses", []))}

This compiles to WASM, deploys to a running PlexSpaces node over HTTP, and runs against a live cluster. The host.* calls map to the exact same primitives the Rust version calls such as fan-out, collect, timeout, all identical in semantics. The full source, including StorageActor, ConnectionActor, RedisCoordinator, and the BenchmarkActor, is in the redis_cluster example.


The Lines That Disappeared

WhatBook (~650 lines)PlexSpaces Rust (~280 lines)PlexSpaces Python (~300 lines)
RESP protocol parser~120 lines (full Ch2)0 (JSON messages)0 (SON messages)
TCP accept loop~40 lines0 (actor mailbox)0 (actor mailbox)
Connection tracking map~30 lines0 (virtual actor lifecycle)0 (virtual actor lifecycle)
MPSC channel setup~20 lines0 (actor framework)0 (actor framework)
Replica list management~40 lines0 (broadcast_shard_group)0 (host.broadcast_shard_group)
WAIT loop (tokio::select!)~50 lines~3 lines (scatter_gather)~8 lines (host.scatter_gather)
Manual fan-out per replica~30 lines~5 lines (broadcast_shard_group)~8 lines
Shard routing / partitioning0 (single node)~1 line (partition_strategy: hash)~1 line
Multi-node placement0 (single node)~2 lines ( NodePlacement::Specific)~2 lines
Coordinated snapshotMissing~5 lines (map_shard_group)~8 lines
Active expiry broadcastMissing~5 lines (broadcast_shard_group)~8 lines

In PlexSpaces implementation includes the StorageActor handlers (get, set, incr, del, expiry logic, replication handlers), the ConnectionActor MULTI/EXEC/DISCARD state machine, and the cluster setup logic.


How to Test Everything

Rust embedded example

cd examples/rust/embedded/redis_cluster

# Run the demo directly — prints all 11 steps with coord_ms timing:
cargo run --bin redis_cluster

# Or run the full validated test suite:
./scripts/test.sh

Python WASM example

Prerequisites: a running PlexSpaces node on port 8091 (and optionally 8093 for multi-node), Python 3.10+, and the plexspaces-py CLI.

cd examples/python/apps/redis_cluster

# Build actors to WASM:
./build.sh

# Deploy, initialize, and test all 11 steps (single-node):
./test.sh
# or explicitly: ./test.sh 8091

# Multi-node (shards distributed across both nodes):
./test.sh 8091 8093

Above example runs two nodes on ports 8091 and 803. The create_shard_group uses from_registry placement to spread shard actors across both nodes automatically. Every collective operation like scatter_gather, reduce, map, broadcast routes cross-node over gRPC, using the same primitives whether the shards are local or remote.

Fixed ports, on purpose

The Rust embedded example starts two in-process nodes on fixed ports, :8091 and :8093, rather than ephemeral ones:

redis-master-node  ->  gRPC :8091
redis-replica-node ->  gRPC :8093

Both examples use the same ports, so you can point the Python test.sh at a cluster the Rust example already stood up, or vice versa. Run ./scripts/test.sh for the Rust side.

What the test scripts actually check

The Rust scripts/test.sh looks for: “Cluster ready”, “Basic operations”, “broadcast_shard_group”, “scatter_gather”, “reduce”, “map + concat”, “parallel map”, “Multi-Node”, “Throughput Benchmark”, “SET/sec”, “p50”, “Example Complete”.

The Python test.sh checks: a setup response with "status".*"ok", GET/SET/INCR semantics, an expired key returning "found".*false, replication returning "acks", a snapshot returning "shard_count" and "shards", and the benchmark returning "tps" and "p50_ms".


Learnings

The purpose of the book was to teach how Redis works using Rust. I rebuilt it on PlexSpaces to show how abstractions can simplify building complex distributed applications. For example, the RESP parser, the connection lifecycle, the replica list, and the WAIT loop are not your job. The redis book teaches you to hand-roll patterns like an mpsc mailbox, a spawn-per-connection loop, a tokio::select! deadline race. ThePlexSpaces uses many of same primitives to define high level abstractions but it provides an actor runtime that hides all complexity. This allows you to build the actual business logic like the storage logic, the replication semantics, the transaction model. Actors just give you a place to put them that scales horizontally without rewriting any of the logic itself. The throughput numbers show rough cost of the actor primitives, e.g., cluster setup is expensive, so you pay it once; individual operations are fast. With p50s in the hundreds of microseconds; bulk operations get much cheaper per key as the coordination overhead amortizes over more keys. This is how distributed systems work in general so you need to measure performance overhead.


GitHub: https://github.com/bhatti/PlexSpaces

Related reading

Example code and documentation:

Powered by WordPress