We Built an Interface So We'd Never Have to Choose a Backend. We Ended Up Deleting All of Them.
By Dan McAulay
Fourth post in the series on how Shipwright actually came together — the first one covered the cloud review pipeline, the second covered OpenClaw, Bodhi, and the original todo list, the third covered the merge into VitalsOS. That post ended with a line I want to pick back up directly: what we were calling a task store back then was still just my personal todo list — not shared with anyone, same as every other agent’s own local queue.
By the time VitalsOS existed, my todo list wasn’t the only one anymore. Every agent running the pipeline — mine, Dave’s, and the ones we were starting to stand up for real engagements — kept its own local queue, same shape as the one I’d been running since Bodhi, but private to that agent. Nobody had a way to look across all of them at once. Dave wanted something specific out of that: visibility. Not wanting to have to ask an agent directly what it was doing, or what was left — being able to just look, across every agent, in one place. So Dave went and extracted an interface that satisfied the same contract my todos.json file had always honored — same shape, same operations — so that whatever sat behind it could change without anything that called it needing to know. That was the whole point of doing it this way: swap what each agent’s local file pointed at for something actually shared, and keep the calling code exactly as it was. What he built first, behind that interface, was a GitHub Issues backend — every repo already had one, in the same place the code and the PRs already lived.
The assumption baked into that decision was that GitHub Issues wouldn’t be the only thing behind the interface — Linear and Jira were supposed to follow eventually too, whichever platform a given team already lived in.
Two Backends, One Confused Agent
Here’s the part that didn’t work: there was a real config telling every agent which backend was current. That should have been enough on its own. It wasn’t. Both backends stayed live behind the interface for a while, and agents kept reaching for whichever one they already knew instead of whichever one the config pointed at — especially the older agents, the ones that had been running against todos.json for a while before GitHub Issues ever entered the picture. Two backends, each holding its own version of what was current, and no reliable way for an agent to know which one to trust — that’s the actual split-brain, and it wasn’t a missing signal. It was old habits outlasting a config change that was supposed to end them.
It wasn’t just todos.json that agents had reason to distrust, either — GitHub hadn’t settled into a stable shape yet. The first attempt used GitHub Projects v2 — a real project board, fields and all — and it lasted about a day before getting replaced with something plainer: status tracked through labels, with the actual task data written into a fenced code block in the issue body and parsed back out on read. Projects v2 needed a wider PAT scope and a much messier field-mapping layer than a label swap did. Simpler won.
GitHub Issues came with a mismatch of its own, too: it’s scoped to a single repo, and the pattern we already had was one task store per agent, covering however many repos that agent actually touched. We ended up funneling multiple repos’ worth of tasks into a single repo’s issue tracker to make that fit — workable, never clean. Jira and Linear would have matched the shape better, real multi-project boards built for exactly this. But they cost money, and we didn’t want to pay for an integration nobody was actually using yet. The plan was to wait for a real client already running one of them, paying for it, and build against their actual use instead of a hypothetical one. Jira eventually did get built for real. No client ever got the chance to use it before it was gone.
Visibility Cut Both Directions
The visibility Dave wanted was real, and it worked — an agent (or a person) could look at GitHub and see the whole queue instead of asking someone to summarize it. But visibility for one agent meant visibility for every agent, and that cut a different way than either of us expected. Every issue was now visible to every agent with repo access, which meant we had to figure out assignment for the first time — and we were doing GitHub auth through personal access tokens, which have no concept of “which agent is this.” Dave was running two agents off his own PAT, and there was no way to tell them apart at the auth layer, let alone assign an issue to one specifically. GitHub Issues has no built-in concurrency control either, so nothing stopped two agents from picking up the same task at the same time. That wasn’t a new failure mode, exactly — the same thing was already happening with PRs — but the task queue was supposed to be the place that didn’t have that problem, and now it did too.
The Fix Wasn’t a Fourth Adapter
We patched around all of this for weeks — more than once. A doctor check that warned if two backends looked configured at once. Then actual enforcement, forcing a single backend to be selected instead of leaving it as a config value an agent could still route around out of habit. The confusion kept coming back anyway.
Looking back, we probably could have chased that down to zero eventually — one more enforcement pass, one more warning. But it was starting to feel like whack-a-mole, and every attempt to make the configuration more explicit to an agent was itself one more thing for it to get confused about. It also wasn’t the only problem by then — the assignment and concurrency issues from the section above were piling up in parallel, and no amount of config cleanup was going to touch either of those.
It helped, but it was still the same shape of system — an interface with a hole in the middle where a real data layer should be, and whatever plugged into that hole inheriting all its host’s limitations, whether that host was a JSON file with no concurrency model at all or an issue tracker never designed to be one.
The actual fix, when it came, wasn’t a better adapter. It was walking away from the adapter pattern entirely. We scaffolded a real service — Postgres behind it, a proper schema for a task instead of a label soup and a JSON blob stuffed in a comment. Auth stopped being a shared PAT and became per-agent tokens, scoped to specific repos, so “which agent is this and what can it touch” finally had a real answer instead of an assumption. Claiming a task became an actual atomic operation against a database instead of a race against whichever agent’s GitHub API call landed first, with a background job that reaps stale claims if an agent dies mid-task instead of leaving a task silently stuck. And once that service existed, we didn’t phase the old backends out gradually — the JSON adapter, the GitHub adapter, and the Jira adapter all got deleted in the same commit. Not deprecated. Removed.
What That Actually Bought Us
The honest version of this story isn’t just “the old way was broken, so we fixed it” — a few things got real once there was an actual database under the task store that were never really possible before, not cleanly.
Any agent with a token scoped to a repo can pick up work in that repo now, full stop — the old world couldn’t offer that as a guarantee, because PAT-based auth had no concept of a scoped agent identity to check in the first place. It wasn’t a limitation we worked around before; it’s a capability that flatly didn’t exist.
We can also just look. The admin app has real tabs — ready, in progress, blocked, closed — backed by the actual state of the actual task, not a label someone remembered to apply. Before, seeing that picture meant different custom querying depending on which platform’s API you were hitting that week. Now it’s a page you load.
Blocked and HITL are the two pieces of that state model we actually lean on day to day, not just theory. A task can land in blocked for two different reasons — a real, unresolved dependency on another task, or a hitl flag meaning it genuinely can’t be finished by an agent alone. That second case runs through its own command now: it loads the task’s context and its human-steps section, hands over full infra tooling — terraform, kubectl, whatever the task actually needs — walks through it with whoever’s driving, and marks the task done once they confirm. That’s not a hypothetical safety valve. It’s a real, regularly-used path for the exact category of work that was never going to be agent-only.
None of this rules out Jira, Linear, or GitHub Issues coming back — it just changes what role they’d play. The task store is what actually drives work now; if we ever build those integrations, they’d exist to propagate visibility out to teams already living in one of those tools, not to be a backend the task store depends on. That’s still just an idea, not a line of code — but it’s the correct shape for it, and it’s the opposite direction of what we’d built before.
We built an interface to avoid ever having to make this decision for real. It took building three backends behind it, watching agents get confused about which one to trust, and hitting a concurrency problem the whole point of a shared queue was supposed to solve, before we actually made it. Every piece of Shipwright has to earn its place by pointing back to a specific problem it solved for a specific person — this one earned it the slow way, by failing twice first.
Next up: the crons and the loop that actually work this queue — why the whole system polls instead of reacting to events, and why a piece called patch had to exist before the rest of the pipeline could narrow back down to what it was actually supposed to do.
Want to accelerate your engineering team?
Book a 30-minute discovery call to discuss your team's AI adoption strategy.
Get in Touch