Four near-identical JIRA tickets showed up seconds apart, with no human in the loop. Our first guess was a flaky board or a double-submit somewhere. Neither was the cause. One automated session had fired our ticket-creation command seven times inside two minutes. It kept firing because a tail -15 at the end of the pipeline ate the success line, so the agent driving the command could never tell that the call it had just made had already worked.

Three small bugs stacked into that one loud failure. The first is the one that transfers. The other two are platform papercuts that turned it from harmless into fatal.

The trace

The mess came from a single session that ran site-new (our “create a new ticket” command) seven times between 01:06 and 01:08. Two of the four tickets were the real work the session was supposed to file. The other two were duplicates, each a retry of the call right before it. Later, while building the fix, we created one more dupe as a smoke test, so the final cleanup deleted three tickets.

So two intended creates, run as seven calls, left two duplicates behind in the burst. Nobody asked the session to retry. It retried because it could not see that the call it had just made had succeeded.

Bug 1: the pipeline truncated its own success line

Every invocation of the command was wrapped like this, to keep the captured output small:

site-new "(Page) Title" ... 2>&1 | tail -15

Capping noisy output at the last 15 lines is a reasonable instinct. The catch is that site-new prints about 18 lines on success, and the line that matters, the CREATED: ITEM-#### confirmation, is line one. Line two is the ticket URL, line three is blank, and the body starts on line four: a --- set --- block of field echoes (type, epic link, Features label, the rendered fields).

So tail -15 did exactly what it was told. It threw away the first three lines, including the CREATED: header and the URL under it, and handed back fifteen lines that began at the --- set --- block. Those were field echoes with no visible success marker. From the agent’s side, the command had produced output but no confirmation that the ticket existed. When a create command appears to have run without confirming success, the safe-looking move is to run it again. And again.

That is the whole trap. A pipeline that truncates a tool’s success line turns a working call into a retry loop. The call worked, and the pipe cut off the evidence. Truncation that drops the header but keeps the body flips the meaning of the output from “succeeded” to “no idea.” On a command with no other side-effect guard, “no idea” plus a retry policy equals duplicates.

Bug 2: MSYS rewrote the argument before the script saw it

The second bug explains why one of the dupes was malformed. The first real attempt was passed --page "/staff-admin/". On Windows, running through git-bash, MSYS’s automatic POSIX-to-Windows path translation looked at that leading-slash argument, decided it was a path, and rewrote it before the Python script ever read it. /staff-admin/ came out as C:/Program Files/Git/staff-admin/, the embedded space split the token, and the Features field was written as two garbage values.

A mangled --page value corrupts Features-label resolution, which is how the ticket gets homed to the right epic. The script did not catch it, because the call also passed an explicit --epic, and supplying one skips the one code path (resolve_epic()) that would have validated the Features label. So the call wrote a ticket with a corrupted routing field, and Bug 1 made it look like that call had halted. The agent retried with a cleaned-up --page "StaffCenter" and got the first real ticket. This git-bash footgun bites any argument that starts with /: MSYS silently rewrites it, and your script receives a string you never typed.

Bug 3: a shell-snapshot init error made a good call look failed

The third bug pushed the loop from the other side. One of the calls (the fifth, at 01:07:02) hit a Claude Code shell-snapshot init error, the classic exit 127 / No such file or directory from a broken snapshot wrapper. The inner eval actually ran and created the second duplicate, but the Bash tool surfaced exit 127 as if the whole thing had failed. On that call, two independent signals pointed the same wrong way: tail -15 hid the success header, and a nonzero exit code from the shell init said “this failed.” A call that succeeded looked failed on both the output channel and the exit channel, and the agent retried it into the second real ticket. Given that evidence, retrying was the correct decision. The evidence was wrong.

Why it took three

None of these alone produces a burst of duplicates. If site-new had been idempotent, the truncated output would not have mattered, because the retries would have been no-ops. If the exit code had been honest, the agent would have trusted success on the call that hit it. If MSYS had left --page alone, the first dup would at least have been clean enough to reason about. It took all three: a create command with no dedup guard, a pipeline that hid the one line proving success, and an exit code that claimed failure on a call that worked. Each one made the others lethal.

The hardening

The fix was three guards, and we confirmed the first two actually fired before we called anything done.

Move the success header to the last line. site-new now repeats CREATED: <key> plus the ticket URL as the last line of its output. Any | tail -N caller physically cannot lose the confirmation, however aggressive the truncation. It is the cheap, universal fix: if any caller might pipe your command through tail, your success marker belongs at the bottom.

A 5-minute duplicate guard. A new find_recent_duplicate() check halts the create if a ticket with the same summary and the same Features label was created in the last 5 minutes. An explicit --allow-recent-duplicate overrides it when you really do mean to file twins. This is the backstop for when the success line is lost anyway: even a blind retry loop can only make one ticket per 5-minute window now.

A page-label assert before the POST. assert_page_label_known() halts before hitting JIRA when --page resolves to a bogus Features label. That catches the MSYS mangling specifically, even when an --epic is also supplied. Bad routing field, no POST.

Both the 5-minute window and the page guard were proven to fire during cleanup, not just written and hoped at.

One cleanup wrinkle: PREPARING-status tickets offer no “close as duplicate” workflow transition, so we could not resolve the dupes the polite way. The three throwaways (the two burst duplicates and the smoke-test dupe) were deleted straight through the JIRA REST API with curl -X DELETE .../rest/api/2/issue/<key>, leaving the two real tickets as the pair. The hardening all landed under one ticket.

If an automated process creates records and reads its results through a step that truncates output, check that the confirmation line survives the truncation, and put the success marker at the end. Make the create operation refuse repeats inside a short window, as ours now does with the 5-minute guard, and validate any input a shell may have rewritten.