The Daemon in the Middle

tacoda1 pts0 comments

The Daemon in the Middle. My laptop runs a Python process that… | by Ian Johnson | Jun, 2026 | MediumSitemapOpen in appSign up<br>Sign in

Medium Logo

Get app<br>Write

Search

Sign up<br>Sign in

The Daemon in the Middle

Ian Johnson

11 min read·<br>Just now

Listen

Share

My laptop runs a Python process that does almost nothing. Every 60 seconds it asks Jira for the list of open tickets, looks at each one, and decides whether anything needs to happen. Most of the time, nothing does. The whole tick takes under a second. Then it sleeps.<br>I recently wrote about intent-driven delivery. A customer signal turns into a contract on a Jira ticket. An agent reads the contract, does the work, posts evidence. A human approves the result. Six steps, three of them mechanical. The middle was supposed to run without a human babysitting it.<br>The daemon is what runs the middle. It’s called iddd (Intent-Driven Delivery Daemon) and it does almost nothing. That’s the whole design.<br>This post is about how the pieces fit. The daemon is not the agent. The daemon doesn’t generate code, doesn’t decide correctness, doesn’t read the contract. The daemon notices a ticket moved into the wrong status and pokes the agent to come look. Everything interesting happens inside the spawned agent process. The daemon is plumbing.<br>That split is the design. Most projects I’ve seen in this space conflate the orchestrator with the agent. They build something smart at the wrong layer. The agent ends up tangled with retry logic, the orchestrator tangled with prompt engineering, and neither is easy to change. Split them and the daemon stays mechanical and testable. The agent stays a Claude Code process spawned with a known command and known inputs.<br>The pieces<br>The Python package is iddd/. A handful of files, none of them long:<br>reconciler.py — a loop on a 60-second interval.<br>derive.py — one pure function that takes a Jira issue and returns the next action.<br>queue.py — a SQLite table with a UNIQUE constraint on a dedupe key.<br>worker.py — pulls jobs and spawns claude -p in a fresh repo clone.<br>jira.py and gitHub.py — adapters. REST and the gh CLI.<br>cli.py — iddd run, iddd status, iddd drain, iddd tail.<br>The deps are small on purpose. apscheduler for the loop. requests for Jira REST. pyyaml for config. sqlite3 from the stdlib for the queue. Nothing else.<br>The shape of a tick<br>A tick is what happens every 60 seconds. The reconciler runs a JQL query against Jira for all open issues in the project. For each one it calls derive_action(issue). That function returns either None (nothing to do, ticket is in a steady state) or a tuple like `(“idd-dispatch”, “PROJ-123”)` meaning “this ticket is ready to be picked up by the dispatch agent.” The reconciler then enqueues the action against the SQLite queue.<br>That’s the whole brain. A polling loop, a pure function, a queue.<br>The pure function is the part I’m proudest of. derive_action doesn’t talk to the network on its own. It takes the issue payload — status, labels, comments, description — and returns the next action by inspection. That makes it easy to test. I have a directory of fixture issues — backlog_no_intent.json, needs_details_with_draft.json, to_do_approved.json, in_progress_pr_open.json — and a pytest run that asserts the right action for each. No mocks, no fakes, no fragile mocking-the-mocking. The function reads inputs and returns a decision. When a bug shows up in the field, I capture the issue payload that triggered it, drop it in fixtures, write a failing test, and fix the function.<br>The queue is the second thing I’m proud of, and it’s the smaller idea. The table looks like:<br>CREATE TABLE jobs (<br>id INTEGER PRIMARY KEY,<br>dedupe_key TEXT UNIQUE NOT NULL,<br>issue_key TEXT NOT NULL,<br>action TEXT NOT NULL,<br>payload TEXT NOT NULL,<br>state TEXT NOT NULL,<br>attempts INTEGER NOT NULL DEFAULT 0,<br>created_at INTEGER NOT NULL,<br>updated_at INTEGER NOT NULL<br>);dedupe_key is {issue_key}:{action} — for example, PROJ-123:idd-dispatch. enqueue() does INSERT OR IGNORE. If a tick fires while the previous tick’s enqueue is still pending in the worker, the second enqueue is a no-op. Dedupe is free, and it’s stored in the database, not in process memory. The daemon can restart mid-job and the queue is intact.<br>The state column is pending | running | done | failed. A worker grabs the lowest-id pending row whose issue_key isn’t already in the running set, transitions it to running, does the work, transitions it to done or failed. Per-issue serialization comes from that “not in the running set” clause: one ticket can only have one job in flight at a time, even though the pool runs multiple workers in parallel across different tickets.<br>What the worker does<br>Here’s the worker’s core:<br>def run_job(job):<br>clone = f"/tmp/iddd-clone/{job.issue_key}"<br>run(["gh", "repo", "clone", GITHUB_REPO, clone])<br>try:<br>env = {**os.environ, "CODE_REPO": clone}<br>cmd = ["claude", "-p", " - output-format", "stream-json",<br>" - dangerously-skip-permissions",<br>f"/{job.action}...

daemon agent null iddd action jira

Related Articles