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.

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.
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 / PR | Topic | Date | Outcome |
|---|---|---|---|
| #39 | The Heartbeat Trap | 2026-06-15 | Merged |
| #40 | Heartbeat Failure Detection | 2026-06-15 | Merged, same-day collision |
| #61 | OWASP Agent Risks | 2026-07-06 | Closed unmerged |
| #70 | OWASP Agent Risks, retry | 2026-07-15 | Merged nine days later |
| Row 1786 → #69 | Railway: Agent-Native Cloud | 2026-07-13 | Closed unmerged |
| Row 1786 → #71 | Railway: Agent-Native Cloud | 2026-07-20 | Still 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.
/ 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.
Related field notes
The Success Penalty: How Our Agent Swarm Got 70× Slower Over 6 Months
Every task your swarm completes makes the next session slightly slower to start until memory gets treated like a database instead of a log file.
An Agent That Can Read Its Own API Key Has Already Leaked It
Why putting secrets in environment variables fails for AI agent swarms, and how egress-time credential injection fixes the credential plane.
Orchestrator Loops Are a Trap: Why Process-Level Orchestration Wins
Why recursive Agent/Task tool calls collapse in production and how Agent Swarm uses independent Claude Code processes with external runners instead.