Back to writing

The regex that stopped at the wrong fence

One evening, Hermes, the pipeline I was running my app's issues through, parked an issue and sent the message its code produces for that branch:

⛔ rn#… parked: implementation session did not report done

The session had reported done. Its last message ended with a fenced json block that said "done": true, with a summary, a PR title, a PR body and a list of assumptions. The implementation was complete and healthy. The pipeline labelled the issue blocked, wrote "Parked" into its ledger comment, and stopped. The agent was fine and the parser was not, and a pipeline that fails closed but mislabels the failure points you at the wrong one.

The regex

Every stage of the pipeline (the griller that interrogates an issue, the dispatcher that runs the implementer, the finisher that lands the PR) ends by asking a model for one fenced JSON object, and reads it back with this:

const re = /```json\s*([\s\S]*?)```/g;
let match,
  last = null;
while ((match = re.exec(text)) !== null) {
  last = match[1];
}
return last;

Take the last json fence, capture lazily up to the next triple backtick, hand that to JSON.parse. It works until the payload contains a triple backtick of its own.

The implementer's contract asked for a prBody written to the repository's pull request template. The template has a "Key change" section, and the agent filled it with a fenced diff block, inside the JSON string. The regression test builds the same shape with a smaller body:

Done.
```json
{"done":true,"prBody":"**What changed:**\n\nStuff.\n\n```bash\nnpm test\n```\n\nCloses #…","summary":"s"}
```

The lazy match runs from the opening json fence to the first fence it meets, the one that opens bash inside prBody. Run against the saved last message, the old regex captures 2,307 characters and stops mid-string, and JSON.parse throws Unterminated string in JSON at position 2307.

The parser caught the throw and returned null. The hub's next line was:

if (!result || !result.done) {
  const reason =
    result?.blockedReason ||
    `implementation session ${
      ok
        ? "did not report done"
        : "failed"
    }`;
  // ...then parks the issue
}

A result that could not be read and a result that said "not done" took the same branch and produced the same sentence. The better the agent followed the template, the more certain the failure.

The fix

The fix replaces the closing-fence search with a walk over the JSON itself. Find each json marker, find the first { after it, then scan one character at a time tracking depth, whether the scanner is inside a string, and whether the previous character was a backslash. Braces and backticks inside a string do not count. When depth returns to zero, that slice goes to JSON.parse.

function extractBalancedObject(text, start) {
  let depth = 0,
    inString = false,
    escaped = false;
  for (let i = start; i < text.length; i++) {
    const c = text[i];
    if (escaped) {
      escaped = false;
      continue;
    }
    if (inString) {
      if (c === "\\") escaped = true;
      else if (c === '"') inString = false;
      continue;
    }
    if (c === '"') inString = true;
    else if (c === "{") depth++;
    else if (c === "}") {
      depth--;
      if (depth === 0) return text.slice(start, i + 1);
    }
  }
  return null;
}

It only finds where one object ends, so the real parser can have it.

The same commit undid a decision. The regex existed in three copies, one each in the griller, the dispatcher and the finisher, and a comment explained why: the modules should stay independently testable and not share helpers across their boundary. That reasoning put one bug in three places. The extractor is now one 44-line file, lib/fenced-json.mjs, that all three import.

Thirty-four minutes later

Thirty-four minutes after the parser fix there was a second one, from a different issue.

The cross-engine auditor had approved a PR. Its verdict arrived as an object with a verdict field and notes. The hub passed verdict.verdict, the bare string, into applyAuditVerdict, which reads .verdict off its argument. "approve".verdict is undefined, so the stored verdict stayed null, and the finisher sat at its audit stage waiting for an approval it had already been given.

Two fixes in one evening for one shape of bug. A good answer became null on the way in. The default on null was to hold, which is the default I want: nothing unsafe shipped either time. And both times the pipeline then described the hold wrongly, once with a false reason and once by stalling. Failing closed kept the code safe. But a fail-closed system that mislabels its own failures sends you to debug the agent when the bug is in the plumbing.

What guards it

tests/fenced-json.test.mjs holds the parser's tests. One is the regression above: a result whose prBody contains a fenced block parses with the body intact. One covers braces and escaped quotes inside strings. One covers garbage, empty input and an object that never closes. And tests/finisher.test.mjs gained a test that applyAuditVerdict accepts both the object and the bare string.

Three things are weaker than they look.

The last test in that file is titled as parsing the real last message the old regex choked on. It reads that file from an absolute path under logs/, which is gitignored, and skips when the file is missing. On my machine it runs. In CI, and on any other checkout, it skips and reports nothing. The synthetic test above it is the actual guard.

The extractor does not fail closed on a malformed block. If the last json block will not parse, it tries the one before it, and a test pins that behavior. The fallback is what lets a payload survive containing a json fence of its own: the inner marker yields an unparseable slice, and the walk falls back to the real block. It also means a truncated final answer can be silently replaced by an earlier draft in the same message. Nothing distinguishes those two cases.

And the sentence that started this was still there when Hermes was retired: a result the hub could not read was still reported as "did not report done". The parser no longer produced that null for this input. The next input that did would have been described the same way.