Back to writing
July 29, 2026·13 min read

The write-only radar: the same story for 21 days.

When every node stays green while producing duplicate work, your data model is lying to you.

content pipelinestate machinededuplicationAI agentsagent-swarm
Gru's four-step plan ends with an agent selecting the same Railway story for 21 days because nobody updated the database
Every node passed. The topic row never got the memo.

On July 29, 2026, our curation agent selected “Railway: The Agent-Native Cloud” as the highest-priority topic for the blog. The problem: it had already been sitting in row 1786 since July 8. In the intervening 21 days, it had generated pull request #69 (closed unmerged on July 13), pull request #71 (still open), and yet there it was at rank one—fresh as a daisy, ready to become PR #73 if we did not catch it.

This is the story of a write-only radar. Discovery mines Hacker News, newsletters, GitHub Trending, and our own inboxes into a ranked Postgres queue. A selection node picks the highest-ranked topic. A writer node ships a blog pull request. And nothing—absolutely nothing—ever writes back to update the queue. Topics are consumed but never change state. The pipeline stays green while producing duplicate work because every node satisfied its output schema. Schema validation is not business-logic validation.

Why Didn't the Pipeline Fail?

The architecture looks clean on paper. The radar—an agent polling external sources—writes to a topics table. A selection agent runs daily, reading the top-ranked row where status = 'pending'. It emits a task to the writer agent. The writer creates a page, opens a GitHub pull request, and exits successfully. Every invocation returns zero. Every checkmark goes green.

But row 1786 never learned about PR #69 or PR #71. It remained status: 'pending', rank 1.0, created at 2026-07-08. The selection agent, being a well-behaved consumer, simply picked the highest-ranked pending topic every morning. The writer agent, being stateless, had no memory of writing about Railway twice already that month.

Nodes validated their outputs, not the system's outcomes. Green logs hid a data model that forgot history.

The Collision Inventory

Across 28 blog pull requests on agent-swarm-landing—21 merged, five closed unmerged, and two open—the duplicates are measurable:

Row / PRTopicDateOutcome
#39The Heartbeat Trap2026-06-15Merged
#40Heartbeat Failure Detection2026-06-15Merged, same-day collision
#61OWASP Agent Risks2026-07-06Closed unmerged
#70OWASP Agent Risks, retry2026-07-15Merged nine days later
Row 1786 → #69Railway: Agent-Native Cloud2026-07-13Closed unmerged
Row 1786 → #71Railway: Agent-Native Cloud2026-07-20Still open on July 29

Row 1787 produced PR #72 and remained rank two throughout the same period, showing that the leak was systematic rather than a one-row accident.

What's Wrong With a Consumed Flag?

The obvious fix is to add consumed = true when the topic is handed to the writer. This would have been catastrophically wrong. PR #69 was closed because the draft was thin—the agent had missed the agent-native architecture angle that made the story interesting. The rewrite became PR #71, which was significantly better and addressed the core thesis. A tombstone would have killed our best post.

The same pattern appears with PR #61 and PR #70. The first OWASP draft was unfocused. Nine days later, the topic resurfaced, the writer agent produced a tighter draft, and we merged it. The row needs an outcome-driven lifecycle, not a gravestone.

  • Merged: tombstone the topic permanently. The content exists.
  • Closed unmerged: decay the rank by 50% and return it to the pool. This is a retry with a penalty.
  • Open: suppress the topic until the pull request resolves. Do not allow concurrent drafts.

The Prompt-Level Patch That Isn't

Our current fix is a hack that betrays the architecture's missing piece. We inject open pull-request titles into the selection agent's task text with the instruction to treat them as already occupied:

// Current workaround in selection-node/task-builder.ts
const buildTask = (topics: Topic[], openPRs: PullRequest[]) => {
  const occupiedTitles = openPRs.map((pr) => pr.title);

  return {
    instruction: `Select the highest-ranked topic from the list below.

ALREADY OCCUPIED (do not select these):
${occupiedTitles.join(", ")}

Available Topics:
${topics.map((topic) => topic.title).join(", ")}`,
    schema: TopicSelectionSchema,
  };
};

This works for three open pull requests. It degrades at 30 as the context fills with noise. It costs tokens on every invocation, linear to the number of open drafts. And it relies on the model to match semantic similarity between “Railway: The Agent-Native Cloud” and a pull-request title like “Add Railway deployment guide.” It is a data-model gap papered over with natural language.

The State Machine We Should Have Built

The row needs to track not just existence, but outcome. This TypeScript schema encodes the lifecycle explicitly:

// types/topic-lifecycle.ts
export type TopicOutcome =
  | { status: "available"; rank: number }
  | {
      status: "suppressed";
      until: Date;
      prNumber: number;
      reason: "pr_open" | "pr_draft";
    }
  | {
      status: "decayed";
      originalRank: number;
      closedAt: Date;
      previousAttempt: number;
      decayFactor: number;
    }
  | {
      status: "tombstoned";
      mergedAt: Date;
      prNumber: number;
    };

interface TopicRow {
  id: number;
  title: string;
  source: "hackernews" | "newsletter" | "github_trending" | "inbox";
  outcome: TopicOutcome;
  createdAt: Date;
  lastModified: Date;
}

The selection query becomes deterministic. No model reasoning required:

SELECT *
FROM topics
WHERE outcome->>'status' = 'available'
   OR (
     outcome->>'status' = 'decayed'
     AND (outcome->>'closedAt')::date + interval '3 days' < NOW()
   )
ORDER BY
  COALESCE(
    (outcome->>'originalRank')::float,
    (outcome->>'rank')::float
  ) * COALESCE((outcome->>'decayFactor')::float, 1.0) DESC
LIMIT 1;

The suppression transition handles the case that burned us. When a pull request opens, its topic becomes suppressed with the pull request number. A GitHub webhook updates the row on close or merge. A merge tombstones it; a close without merge decays it and returns it to the pool. The selection node never sees row 1786 again until the previous draft resolves.

Why Artifact-Based Deduplication Fails

Here is the uncomfortable truth: in an agentic pipeline, any deduplication keyed on published artifacts is broken by construction, because agents merge slower than they write.

If you wait to tombstone a topic until its pull request merges, you will generate duplicates in the gap between “pull request opened” and “pull request merged.” Human review takes hours or days. Agent writers take minutes. The velocity mismatch means the radar can propose the same topic multiple times before the first draft clears review, unless you track intent separately from outcome.

Semantic similarity of generated content does not rescue the design. Two drafts about Railway might score 0.7 cosine similarity while one is a deployment guide and the other is an architecture analysis. You are racing a non-deterministic process. The topic row is the only source of ground truth.

What We Changed

We added an outcome column and a GitHub webhook handler that updates topic state on pull-request events. The selection node now filters on state, not just rank. The already-occupied check moved from the model prompt into the SQL query where it belongs.

The 21-day life of row 1786 taught us that green checks are not enough. In a system of autonomous agents, the data model must encode the business lifecycle. Otherwise, you are not building a pipeline. You are building a very fast photocopier.

Build the return path. If your queue is write-only, your agents are flying blind. Model the outcome, not just the existence.

/ references

Sources and further reading

FAQ

Why not just check GitHub before writing?

External checks at generation time create race conditions and couple the pipeline to external APIs. The queue should own the state, not reconstruct it at selection time.

How do you handle pull requests closed for quality issues?

Decay the topic's rank by 50% and return it to the pool after a cooldown. Closed-unmerged is a retry signal, not a tombstone.

What is wrong with the prompt-level fix?

It costs tokens in proportion to the number of open pull requests, degrades as the list grows, and relies on semantic LLM reasoning instead of deterministic state.

Why not use a simple consumed boolean?

Content pipelines need retries. A boolean cannot distinguish between never tried and tried but failed and needs a rewrite.

When should a topic be permanently tombstoned?

Only on merge. Closed-unmerged stays in the pool with decayed priority; open pull requests suppress the topic until resolution.

/ keep reading
/ get started

Build your swarm tonight.

A 7-day free trial on Cloud, or fork it on GitHub. Either way, your agents start compounding today.