A Jira-Driven, Worktree-Isolated Pipeline for Unattended AI Coding Agents
I was standing in line at a cafe, waiting on my order, running through ideas for the web project. By the time I sat down, I had a few worth doing. The food hadn’t come out yet, so I opened Jira on my phone and wrote the tickets right there at the table — enough intent in each one to work from — and dropped them straight into Ready for Planning.
Food showed up, I ate. When I picked my phone back up, the tickets weren’t sitting untouched anymore — each one had a plan waiting for me in Plan Review. I read through them at the table, on my phone, and moved the ones I was happy with into Ready for Implementation.
I paid, drove home, sat down at my desk — and three branches were already there. Implemented, tested, done. Nothing left for me but to review the diffs and merge into develop. That’s the moment this stopped being a proof of concept for me — three real features, done, while I was somewhere else entirely.
That’s the loop this piece describes: three tickets written from a cafe table turn into three branches waiting at home. Here’s what I built to make that handoff something I was willing to leave unattended. I want to be upfront about what this is before getting into how it works: a personal experiment, not a finished tool or a claim that this is the right way to do this. I also want to be upfront about how it was built: I didn’t hand-write the shell code that runs everything — I prompted Claude for it, reviewed what came back, and tested until it worked. The design decisions in this piece are mine; the implementation is Claude’s. I’m using it to learn what AI-driven workflows can actually do for me, on this project and beyond, and the rest of this piece is what I’ve found so far, and why I made the calls I made.
What it does
A poller runs on a short cron interval and watches three Jira statuses: Ready for Planning, Ready for Implementation, and In Progress. Moving a ticket into one of those statuses is the only trigger — there’s no separate queue or callback notification. Each poll does three passes:
- Planning pass. For every ticket in
Ready for Planning, dispatch a planning worker. It writes a plan and moves the ticket toPlan Review. - Implementation pass. For every ticket in
Ready for Implementation, set up an isolated worktree and start an implementation worker in the background, up to a limit on how many can run at once. - Watchdog pass. Sweep
In Progressfor workers that have died or hung, and flag them for a human.
Here’s the flow, with Jira’s own status names mapped through an adapter:
Backlog → Selected → Ready for Planning → Needs Author Input ⇄ Ready for Planning
→ Plan Review → Ready for Implementation → In Progress
→ Ready for Verification → Done
Of these moves, the automation performs exactly four on its own. The one it never performs is Plan Review → Ready for Implementation. That one has to happen by hand — the move I made on my phone at the cafe table.
Why I left the approval step out
I could have written a prompt instruction telling the planning worker never to move a ticket past Plan Review without human sign-off. I didn’t, because a prompt instruction is a request a model can fail to honor — through an ambiguous ticket, a model that’s lost track of earlier context, or a prompt edit I make later without thinking it all the way through.
Instead, the tracker adapter simply has no function that performs that move. There’s no way to get there in the code. A worker can’t cross it by mistake because the code to cross it doesn’t exist.
I didn’t skip full end-to-end automation because I ran the numbers and decided the risk was too high. I skipped it on principle — letting AI skip the human checkpoint is exactly the mistake I wanted to design out. Every ticket now waits on someone available to approve it before implementation starts — even if “available” just means a phone at a cafe table. That’s on purpose, not friction I plan to optimize away later.
Isolation: worktree, container, and database per ticket
That missing move only protects the one ticket it’s attached to. If a dispatched run shared a database or a branch with anything else, a bad run could still damage something shared before a human ever reviewed it. So every dispatched ticket — planning or implementation — gets its own git worktree, its own Docker Compose project, and its own PostgreSQL database, built fresh from the same migration scripts used in production instead of copied from an existing database.
Building the database from migrations instead of cloning is a deliberate trade-off. It’s slower and starts empty — no environment-specific sample data, no uploaded images, nothing beyond what the seed data provides. In exchange, every ticket’s setup also keeps checking that every migration still runs cleanly from scratch — something a copied database would never catch. Cloning is still there if you choose it, for when a full data snapshot is actually needed; it just isn’t the default, because a shared database breaking is worse than a slower, emptier one.
Git worktrees and Docker Compose weren’t designed with each other in mind, and making them cooperate was most of the actual engineering effort:
- Compose project name derived from a short, safe version of the worktree’s folder name, so container names, ports, and networking don’t collide across worktrees (
proj-appon main,proj-feature-xyz-appon a feature worktree). - A
compose.override.yamlper feature worktree locking it toimage: proj-app:latest, so Compose reuses the already-built image instead of rebuilding from the wrong directory. - A per-worktree, gitignored
.env.localcarryingAPP_PORT,XDEBUG_PORT, and a per-worktreePOSTGRES_DBname. These also have to be exported into the shell, not just saved in the file — Compose gives shell environment variables priority over--env-file, so writing the file alone doesn’t work. - One shared PostgreSQL server, started once via its own
compose.db.yaml, hosting one database per worktree. App containers reach it overhost.docker.internal:5432instead of a shared Docker network.
This is also why I didn’t have to worry the three branches waiting for me at home had stepped on each other before I’d even opened the diffs: each one had built and tested against its own database, not one shared with whatever else might have been running.
A second guardrail: a permission profile
Unattended workers run under a curated allow/deny list: build, test, verify, and local git are allowed; push, deploy, SSH, and reading env/secrets are denied. I don’t rely on prompt instructions to keep the agent in bounds. For this experiment, the permission profile is what I actually trust to hold — an instruction is still just a request, not enforcement.
Coordination and recovery
The poller and its detached workers don’t talk to each other directly — they leave files on disk for each other to read, in a folder that never gets committed to git. One file tracks context for a ticket, one tracks decisions made along the way, one tracks which worker slots are busy, and one keeps a record of every attempt. I chose this over a database or message queue because it’s a single-host tool meant to drop into a repo without extra infrastructure. The cost is handling stale files by hand: clearing out old markers and cleaning up worker slots whose process already died.
A setting (..._MAX_WORKTREES, default 2) limits how many implementation workers can run at once, since each one sets up a container and a database and drives an agent — expensive enough that letting too many run at once would overload the machine, and burn through my Claude usage just as fast. I ended up upgrading to Claude Max after setting this up, because running it without a cap ate through my usage a lot faster than I expected. Extra ready tickets simply wait for a slot on a later poll.
The watchdog allows a limited number of retries (..._MAX_ATTEMPTS, default 3) before it flags the ticket for a human instead of restarting again. I added this after detached workers died silently with no way to recover. It’s deliberately not unlimited retries — that risks looping forever on work that’s genuinely stuck. And it’s not a blind restart of anything dead either — that would just repeat a failure the worker already reported. It only acts on tickets it can prove it dispatched, using a record that’s still there even after the temporary slot files are gone — so a ticket a human moved to “in progress” by hand is never touched.
Adapters
The poller itself knows nothing about Jira, Symfony, or any specific AI provider. Three adapters, each with a fixed set of things it has to do, sit behind one config file:
- Tracker adapter — every search, read, comment, and status change goes through REST with a single API-token account. Dispatched workers never call the tracker directly. I looked at letting them talk to it through an agent/MCP tool instead, and rejected it for two reasons. First, that would need an interactive login, which breaks running unattended from cron. Second, it wastes tokens: a cron job hitting REST directly costs nothing, while routing the same polling and status checks through an agent’s tool calls spends context on work that doesn’t need a model at all. The cost of going through REST: every automated comment shows up as posted by one shared account. That’s why every comment gets a footer marking it as automated, added in the one place where comments actually get posted.
- Project/stack adapter — keeps the specific tech stack (Symfony/Docker/PostgreSQL here) out of the shared code.
- AI provider adapter — a fixed set of three things every provider has to do: check the environment, run planning, and run implementation. The primary CLI agent is the default provider; a placeholder provider fails loudly; an optional local-LLM provider reroutes that same call through a local translation layer instead, and you can turn it on for a specific ticket with a tracker label. I haven’t verified the local-model path end-to-end from a host that can actually reach it, so I’m not calling it confirmed working yet.
I ruled out two other options: forking the whole system per project, which means maintaining copies that slowly drift apart, and one big script full of if/else branches for every tracker and stack, which bloats the shared code and mixes in details that don’t belong there. The cost of the adapter approach: a new target still needs a real adapter implementation, not just a config flag. Each adapter currently has exactly one real implementation behind it, so I don’t yet know if any of the three boundaries were drawn in the right place.
The plan file
A single committed plan file is the connection between planning and implementation — the thing I was actually reading on my phone at the cafe table. It’s tracked in git from the moment it’s created. When a ticket comes back for another pass, the same file gets updated in place instead of being rewritten from scratch. It always lives on the ticket’s feature branch, never on main, and its status field is what drives the whole workflow. I rejected passing plan content between phases without saving it anywhere — there’d be nothing durable for a human to actually review before approving, and nothing I could have pulled up on a phone screen between bites.
What actually broke
Every failure I hit while building this traced back to infrastructure state disagreeing with itself, not to agent judgment or prompting.
- A feature worktree’s app container wrote to the shared main database instead of its own. The database existed, the config file was correct, the per-worktree override was correct. The container had cached its environment at creation time and never picked up the override. Editing
.env.localafter the container already existed didn’t change what the running container saw. Fix: recreate the container. No code changed. - A per-worktree database name was too long and collided with the test database. The name generated from the worktree slug got silently truncated, and the truncated form matched the one already in use for the test database — so a worktree’s “fresh” database was actually the test database, shared and already dirty. Fix: keep the generated name short enough to survive truncation intact.
- A directory rename broke scripts that only live on my machine. Moving the automation scripts into their own subdirectory broke a gitignored, host-only cron wrapper and the worker’s final comment and status-change step, because both intentionally live outside version control and don’t get updated by a repo-internal rename. Now caught by a one-shot status/health command run after any such move.
node_modulesis symlinked from a worktree back to the main checkout. This breaks silently if a feature branch changespackage.jsonwithout re-runningnpm installin that worktree.- Skipping the one-time bundle-asset install in a new worktree produces a specific visual bug: the admin panel loads with no CSS, dropdowns expand to full page height, icons stretch to fill the viewport. Cause: the framework’s bundled assets were never symlinked into that worktree’s public directory.
- A bind-mounted cache/log directory gets created
root:rootby Docker on a worktree’s first container start, blocking the app from writing its own cache until it’schown’d once. - The footer marking comments as automated ended up solving a second problem it wasn’t built for. It exists to distinguish AI comments from human ones under one shared tracker account. It turned out to also be the watchdog’s reliable signal that a worker had already reported back, which meant a second check — built specifically to detect when a worker finished — never had to be written.
Trade-offs, stated plainly
- Shared code + adapters over a fork or a monolithic script: gets you one shared, testable codebase at the cost of every new target needing a real adapter implementation.
- REST-only tracker access over letting workers call the tracker directly: gets you unattended cron operation with no interactive-login dependency, at the cost of needing the automated-comment footer.
- A committed plan file over passing plans around without saving them: gets you a durable, versioned artifact for the human approver to read — from anywhere, including a phone — at the cost of refining a plan in place across re-pickups instead of regenerating it.
- Fresh-seeded per-ticket databases over cloning: gets you continuous migration verification and full data isolation, at the cost of slower setup and no environment-specific assets.
- File-based coordination over a queue or database: gets you zero extra infrastructure to run the tool, at the cost of handling staleness by hand.
Open
Each adapter — tracker, project/stack, AI provider — only has one real implementation so far. The tracker adapter probably matters most, since that’s where the missing approval move lives. The AI-provider side is the shakiest, since the local-model path is still unverified end to end. The next real step is building a second implementation of one of these and finding out whether the boundary was drawn in the right place.
Status
This is a personal experiment, not a finished tool, and I’m treating it that way. I’ve pulled it out of the project it was built for and into its own repo with a README, but that’s as far as it’s gone so far — I haven’t actually dropped it into another project or tested it anywhere else yet. The plan is to reuse the same pattern in other personal projects, and that’ll be the real test of whether the adapter boundaries hold up, faster than any amount of reasoning about them in the abstract. I’m also thinking through a version for my day job, where the same idea applies but the environment doesn’t: I have far less control there — shared infrastructure, an existing automated testing setup, permissions that aren’t mine to change — so that version won’t look like a straight copy of this one.
The code is up at github.com/davindermahal/ai-intake-harness. As above: I prompted, reviewed, and tested my way to that code — I didn’t write the shell scripts in it by hand.