<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: jidonglab</title>
    <description>The latest articles on DEV Community by jidonglab (@ji_ai).</description>
    <link>https://dev.to/ji_ai</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3791767%2F6eb19afc-a99c-4736-9d12-459108893a16.png</url>
      <title>DEV Community: jidonglab</title>
      <link>https://dev.to/ji_ai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ji_ai"/>
    <language>en</language>
    <item>
      <title>Refmade Shipped Without Git: 2,764 Files in One Snapshot</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Thu, 05 Nov 2026 20:46:46 +0000</pubDate>
      <link>https://dev.to/ji_ai/refmade-shipped-without-git-2764-files-in-one-snapshot-1fdp</link>
      <guid>https://dev.to/ji_ai/refmade-shipped-without-git-2764-files-in-one-snapshot-1fdp</guid>
      <description>&lt;p&gt;On October 7 I asked an agent why Refmade was not on GitHub. The answer was short: it had never been in Git. Seventeen days of work, from September 20 to October 7, and every installer I had published up to 0.3.2, existed only as a folder on a laptop.&lt;/p&gt;

&lt;p&gt;This is part 13 of "Building Refmade", a series about a desktop app I am building mostly with Claude Code and Codex. I plan, decide and review. The agents write most of the code. Refmade draws the Claude Code and Codex sessions running on your computer as an office, so you can see which sessions are working and which are waiting for you. It is an independent app, not affiliated with Anthropic or OpenAI. The UI is Korean right now; that changes later in this series.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a project goes 17 days without &lt;code&gt;git init&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Nobody decided to skip Git. The folder started on September 20 as a personal structure for organizing agents, not as a product. Nothing in it said "repository", so nothing ever asked for one.&lt;/p&gt;

&lt;p&gt;I do have a standing rule that code gets committed and pushed automatically after verification. That rule runs inside repositories. This folder was not one, so the rule never fired and never complained.&lt;/p&gt;

&lt;p&gt;Deploys hid the gap too. The website went out with the Vercel CLI straight from a local folder. For rollback I noted the previous deployment each time, so "go back" meant "redeploy the older deployment", not "check out an older commit". On September 24 I told Codex to wrap up because I was moving the work to Claude Code. The handoff note it wrote said, in plain text, that the folder was not a Git repository. Nobody acted on that line, including me.&lt;/p&gt;

&lt;p&gt;The only version control I had was a tarball. Before the big office rewrite on September 25, an agent packed &lt;code&gt;_backup/cli-before-office-p0-2026-09-25.tgz&lt;/code&gt;. That is a snapshot, not a history.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;What existed&lt;/th&gt;
&lt;th&gt;Where the history lived&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sep 20&lt;/td&gt;
&lt;td&gt;Agent team folder&lt;/td&gt;
&lt;td&gt;Nowhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 25&lt;/td&gt;
&lt;td&gt;Before the office rewrite&lt;/td&gt;
&lt;td&gt;One tarball&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 28&lt;/td&gt;
&lt;td&gt;Desktop app 0.2.x installers&lt;/td&gt;
&lt;td&gt;File timestamps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 1&lt;/td&gt;
&lt;td&gt;0.3.0 and 0.3.1 public&lt;/td&gt;
&lt;td&gt;Deployment list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 2&lt;/td&gt;
&lt;td&gt;0.3.2 public&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 7, 21:44&lt;/td&gt;
&lt;td&gt;First commit&lt;/td&gt;
&lt;td&gt;Git, finally&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A second Mac, and source inside an installer
&lt;/h2&gt;

&lt;p&gt;The strangest part came between October 1 and October 6. I had a second Mac where Codex worked on a separate line of experiments: an easier first screen, a model-and-effort router and some automation ideas. The latest source was not on that machine. It only had what had been installed.&lt;/p&gt;

&lt;p&gt;The agent found a workaround. A packaged Electron app carries its own JavaScript, so the installed 0.3.1 build contained the app source. The agent copied 1,034 files out of it, confirmed the originals' SHA-256 hashes did not change, and built the experiments on that copy.&lt;/p&gt;

&lt;p&gt;It worked, and it meant reading my own product back out of an installer.&lt;/p&gt;

&lt;p&gt;On October 6 an agent searched GitHub for the real source. An older public repository with the same name holds a gallery site last touched in July, a completely different product that I had stopped working on. The desktop app was not there. The status recorded that day was "source location pending". On October 8 the experiments from the second Mac were merged into the main tree as one commit: &lt;code&gt;integrate easy-start workspace into Refmade desktop&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The underlying problem was that two machines each held part of the work. As far as I can reconstruct it, my main Mac had the latest source but not the experiments, and the second Mac had the experiments but not the latest source. A branch and a remote would have given both a single place to meet. "Source location pending" is a strange status to write about your own product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first commit
&lt;/h2&gt;

&lt;p&gt;On October 7 an agent created a private repository and pushed, at my request. The first commit has 2,764 files and about 105 MB. It is one snapshot commit that says "start Git here", with no history behind it. The commit is dated October 7 and does not pretend to be older.&lt;/p&gt;

&lt;p&gt;What I kept out mattered as much as what went in:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Excluded&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;runtime/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run logs and 33 copies of old deployments, about 5.4 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_e2e/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;End-to-end output that included a browser's cookies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_backup/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tarballs of earlier states&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;dist/&lt;/code&gt;, &lt;code&gt;node_modules/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Rebuilt by the build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screenshots, raw generated images&lt;/td&gt;
&lt;td&gt;Large&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;.gitignore&lt;/code&gt; groups its rules by what they protect against. An excerpt, with the comments translated from Korean and other lines left out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# 1. secrets: env files, keys, credentials, token caches, browser profiles
.env
.env.*
credentials*.json
Cookies
# 3. run records, state, caches, build output
runtime/
_e2e/
_backup/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cookie patterns have a reason. The end-to-end folder held a browser's cookies, so the browser-profile patterns sit in the file and &lt;code&gt;_e2e/&lt;/code&gt; is ignored whole.&lt;/p&gt;

&lt;h2&gt;
  
  
  17 gitleaks findings
&lt;/h2&gt;

&lt;p&gt;Before the push, gitleaks ran over the tree and reported 17 findings. Each one was opened and compared against its source. All 17 were fake values inside tests: JWT-shaped strings, token-shaped strings and invented key names used as fixtures. None was a real credential.&lt;/p&gt;

&lt;p&gt;A scanner cannot tell a fixture from a leak, so the comparison is the useful part. I keep that step on every first push, even for a private repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I lost, and what I changed
&lt;/h2&gt;

&lt;p&gt;The cost is history. I cannot restore the September 21 dashboard from Git, because Git starts on October 7. What remains of that day is file modification times and screenshots. The same goes for every 0.2.x and 0.3.0 to 0.3.2 build: I have notes and deployment records, not the diffs.&lt;/p&gt;

&lt;p&gt;After the snapshot, the rhythm changed fast. The Git log shows 15 commits dated October 8 alone, the day five releases went out (that day is the subject of part 14). I could not have done that safely without being able to look at yesterday's state of a file.&lt;/p&gt;

&lt;p&gt;Refmade was not the only one. The same day, 13 folders on my machine, this one included, went into private repositories, each with its own ignore file covering secrets, operational data, run logs and build output.&lt;/p&gt;

&lt;p&gt;The standing rule is now stricter: any folder with work in it gets &lt;code&gt;git init&lt;/code&gt;, a gitleaks pass and a private remote before it ships anything. Today the working tree tracks 3,062 files across 38 commits.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0uokaoyasvcij76n779r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0uokaoyasvcij76n779r.png" alt="Refmade decision card in the pastel theme, with a recommended answer highlighted" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Refmade 0.3.9, Korean UI, public demo data. A decision card: "Tell me just this", then "What tone should I use to reply to the customer?", with a highlighted "AI recommended: Kind and short" option. This is the app that the tracked files build.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would check now
&lt;/h2&gt;

&lt;p&gt;Git was optional for me only because every other step (build, deploy, rollback) had a substitute that felt like version control. Tarballs and deployment lists do not give you diffs. A quick test: can you answer "what changed between yesterday's build and today's" without the person who built it?&lt;/p&gt;

&lt;p&gt;For 17 days I could not do that reliably. Part 14 covers the releases that came right after, and the checks that caught bugs in them. The app link comes in the last post of the series.&lt;/p&gt;

&lt;p&gt;If you work with agents that create files for you, when do you make the first commit: before the first line of generated code, or once there is something worth keeping?&lt;/p&gt;

</description>
      <category>git</category>
      <category>ai</category>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Claude Code Env Vars Don't Persist: 3 of 80 Runs Fell Back to Prod</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Thu, 05 Nov 2026 00:43:59 +0000</pubDate>
      <link>https://dev.to/ji_ai/claude-code-env-vars-dont-persist-3-of-80-runs-fell-back-to-prod-p85</link>
      <guid>https://dev.to/ji_ai/claude-code-env-vars-dont-persist-3-of-80-runs-fell-back-to-prod-p85</guid>
      <description>&lt;p&gt;I told Claude Code &lt;code&gt;export APP_ENV=staging&lt;/code&gt;. My next message said to run the script. It printed &lt;code&gt;target = production&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nothing got deployed. &lt;code&gt;target.py&lt;/code&gt; is a one-line stub in a throwaway repo, built around &lt;code&gt;os.environ.get("APP_ENV", "production")&lt;/code&gt;, a default you've probably seen in real code. But Claude Code env vars don't persist between Bash tool calls, and I wanted to know how often that actually lands. So I ran 97 headless sessions and counted.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every Bash tool call in Claude Code gets a fresh shell. &lt;code&gt;export&lt;/code&gt;, &lt;code&gt;source .venv/bin/activate&lt;/code&gt;, functions, aliases and &lt;code&gt;umask&lt;/code&gt; all vanish before the next call.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cd&lt;/code&gt; is the exception, and only when the command succeeds and stays inside the project. In subagents it didn't carry at all.&lt;/li&gt;
&lt;li&gt;Claude usually works around this by repeating the export. In 80 task runs it missed 3 times, and all 3 ran the script with the fallback value.&lt;/li&gt;
&lt;li&gt;Each miss was reported honestly, after the script had already run.&lt;/li&gt;
&lt;li&gt;For a real fix, set state at session start: &lt;code&gt;env&lt;/code&gt; in &lt;code&gt;settings.json&lt;/code&gt; for plain values, a &lt;code&gt;SessionStart&lt;/code&gt; hook writing to &lt;code&gt;$CLAUDE_ENV_FILE&lt;/code&gt; for a virtualenv.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why don't Claude Code env vars persist between Bash calls?
&lt;/h2&gt;

&lt;p&gt;Claude Code env vars don't persist because each Bash tool call starts a new shell, so anything held in shell memory dies with that call. Only the working directory is tracked across calls.&lt;/p&gt;

&lt;p&gt;I set eight kinds of state in one call and read them back in the next. All 3 runs gave identical results.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Set in call 1&lt;/th&gt;
&lt;th&gt;Seen in call 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;export LAB_EXPORT=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;empty&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LAB_PLAIN=2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;empty&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;source .venv/bin/activate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;VIRTUAL_ENV&lt;/code&gt; empty, no &lt;code&gt;python&lt;/code&gt; on PATH&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lab_fn() { ... }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;not found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;alias lab_alias=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;not found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;umask 077&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;back to &lt;code&gt;022&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;set -o noclobber&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cd sub&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;kept&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can see it for yourself with one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'Make two separate Bash calls, one at a time: first "export FOO=1", then "echo FOO=$FOO". Show me the second output verbatim.'&lt;/span&gt; &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; Bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It comes back &lt;code&gt;FOO=&lt;/code&gt; with nothing after the equals sign.&lt;/p&gt;

&lt;p&gt;The trap is that row at the bottom. &lt;code&gt;cd&lt;/code&gt; working makes the tool feel like one long terminal session, so you assume the rest of your shell came along too.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does cd persist in Claude Code?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;cd&lt;/code&gt; persists in the main session when the command succeeds and ends inside the project. Break either condition and the shell stays where it was.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Next &lt;code&gt;pwd&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cd sub&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;proj/sub&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cd /tmp&lt;/code&gt; (outside the project)&lt;/td&gt;
&lt;td&gt;back at &lt;code&gt;proj&lt;/code&gt;, with the note &lt;code&gt;Shell cwd was reset to ...&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cd sub &amp;amp;&amp;amp; false&lt;/code&gt; (exit 1)&lt;/td&gt;
&lt;td&gt;still &lt;code&gt;proj&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cd sub&lt;/code&gt; inside a subagent, then &lt;code&gt;pwd&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;still &lt;code&gt;proj&lt;/code&gt; (2 of 2 runs)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The subagent row is the sneaky one. &lt;code&gt;cd sub&lt;/code&gt; prints nothing and exits 0, then the next call is back at the root. A relative path written after that lands somewhere you didn't plan. Subagents do start in the main session's current directory, though: after &lt;code&gt;cd sub&lt;/code&gt; in the parent, a subagent's first &lt;code&gt;pwd&lt;/code&gt; was &lt;code&gt;proj/sub&lt;/code&gt; in 2 of 2 runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How often does Claude actually trip on this?
&lt;/h2&gt;

&lt;p&gt;Rarely. Across 80 runs of ordinary-sounding tasks on Sonnet 5.5, Claude ended up with the wrong environment 3 times, and all 3 involved a variable with a silent default.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task (10 runs each)&lt;/th&gt;
&lt;th&gt;Correct&lt;/th&gt;
&lt;th&gt;Wrong&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Export, then run the script&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export, list files, read script, run it&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export, then a subagent runs the script&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export, 8 unrelated Bash calls, then run&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export in one message, run in the next&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Activate venv, then run&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Activate venv, then a subagent runs it&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Activate venv in one message, run in the next&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The model knows this rule. It wrote &lt;code&gt;export APP_ENV=staging &amp;amp;&amp;amp; python3 target.py&lt;/code&gt;, re-exported in each command, and briefed subagents with the variable spelled out in their prompt. That last part surprised me. I expected delegation to be the weak spot, and it scored 20 of 20.&lt;/p&gt;

&lt;p&gt;The misses came from two places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Distance.&lt;/strong&gt; In one run Claude issued &lt;code&gt;export APP_ENV=staging&lt;/code&gt; as its own call, did eight other things, then ran a bare &lt;code&gt;python3 target.py&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A message boundary.&lt;/strong&gt; In all 10 of these runs, Claude skipped the bare &lt;code&gt;export&lt;/code&gt; and warned that it wouldn't last. My next message ignored the warning and asked for the run. Eight times it prefixed the command with &lt;code&gt;APP_ENV=staging&lt;/code&gt; on its own. Twice it ran the bare command.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That second miss is partly on me, and I think it's the realistic one. You skim the reply, type the next instruction, and move on.&lt;/p&gt;

&lt;p&gt;All 3 misses were reported in the final reply. "It was not set when &lt;code&gt;target.py&lt;/code&gt; ran." That sentence is useful after a read-only script. After &lt;code&gt;terraform apply&lt;/code&gt; it's a postmortem.&lt;/p&gt;

&lt;p&gt;Ten runs per pattern is too small to quote as a failure rate. The finding I'd trust is the split: 3 misses in 50 variable runs, 0 in 30 venv runs. A missing dependency throws &lt;code&gt;ModuleNotFoundError&lt;/code&gt;. A missing variable with a default throws nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you make Claude Code env vars persist?
&lt;/h2&gt;

&lt;p&gt;Set them before the session starts, since nothing Claude runs mid-session survives the call. I tested four ways.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;&lt;code&gt;APP_ENV&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;venv&lt;/th&gt;
&lt;th&gt;Subagent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;env&lt;/code&gt; in &lt;code&gt;.claude/settings.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;works&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;not tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Activate, then launch &lt;code&gt;claude&lt;/code&gt; from that shell&lt;/td&gt;
&lt;td&gt;works&lt;/td&gt;
&lt;td&gt;works&lt;/td&gt;
&lt;td&gt;not tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SessionStart&lt;/code&gt; hook writing to &lt;code&gt;$CLAUDE_ENV_FILE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;works&lt;/td&gt;
&lt;td&gt;works&lt;/td&gt;
&lt;td&gt;works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CLAUDE_ENV_FILE=./env.sh claude&lt;/code&gt; at launch&lt;/td&gt;
&lt;td&gt;works&lt;/td&gt;
&lt;td&gt;works&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;APP_ENV&lt;/code&gt; works&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Plain values:&lt;/strong&gt; use &lt;code&gt;env&lt;/code&gt; in settings. It's the smallest fix.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"APP_ENV"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"staging"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It can't run anything, though. With this alone, &lt;code&gt;python3&lt;/code&gt; still resolved to &lt;code&gt;/usr/bin/python3&lt;/code&gt; and &lt;code&gt;check.py&lt;/code&gt; died with &lt;code&gt;ModuleNotFoundError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Virtualenvs and anything that needs a script:&lt;/strong&gt; use a hook. Whatever it appends to &lt;code&gt;$CLAUDE_ENV_FILE&lt;/code&gt; applies to every later Bash call, subagents included.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"SessionStart"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"printf 'source &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;%s/.venv/bin/activate&lt;/span&gt;&lt;span class="se"&gt;\"\\&lt;/span&gt;&lt;span class="s2"&gt;nexport APP_ENV=staging&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;n' &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$CLAUDE_PROJECT_DIR&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &amp;gt;&amp;gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$CLAUDE_ENV_FILE&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt;, so you don't wipe what another hook wrote. Put per-developer values in &lt;code&gt;.claude/settings.local.json&lt;/code&gt; and keep secrets out of anything committed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can Claude change the environment during a session?
&lt;/h2&gt;

&lt;p&gt;No, not in anything I tried. The env file behaves as a snapshot taken at session start.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With the hook setup, &lt;code&gt;$CLAUDE_ENV_FILE&lt;/code&gt; was empty inside the Bash tool, so Claude had nothing to append to.&lt;/li&gt;
&lt;li&gt;With the launch variable, the path was visible and the file could be edited. I appended &lt;code&gt;export APP_ENV=edited-midsession&lt;/code&gt;, confirmed it with &lt;code&gt;cat&lt;/code&gt;, and the next call still said &lt;code&gt;staging&lt;/code&gt;. Same result in 2 of 2 runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need to switch from staging to prod, restart the session. For a single command, ask for the inline form: &lt;code&gt;APP_ENV=prod python3 target.py&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should scripts default to production at all?
&lt;/h2&gt;

&lt;p&gt;No, and this is the cheapest fix in the post. All 3 misses needed a fallback to go wrong quietly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# silently picks prod when the variable is missing
&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APP_ENV&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# stops right here with a KeyError
&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APP_ENV&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second line turns every miss in that table into a crash before any work happens. Default to the safe environment if you must have a default. Anything an agent can run should fail loudly on missing config.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did this test cover?
&lt;/h2&gt;

&lt;p&gt;Claude Code 2.1.318 on macOS with zsh, headless &lt;code&gt;claude -p&lt;/code&gt; with Sonnet 5.5, project settings only, 97 sessions at about $2.08 of list-price usage. I didn't measure interactive sessions, other models or Linux, so run the one-liner above on your own setup first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do Claude Code env vars persist between Bash calls?
&lt;/h2&gt;

&lt;p&gt;No. Claude Code env vars set with &lt;code&gt;export&lt;/code&gt; in one Bash tool call are gone by the next, along with activated virtualenvs, functions, aliases and &lt;code&gt;umask&lt;/code&gt;, because every call starts a fresh shell. Only &lt;code&gt;cd&lt;/code&gt; carries over, and only in the main session on a successful command that stays inside the project. Claude works around this most of the time (77 of 80 runs here), but for anything that matters, define the state before launch with &lt;code&gt;env&lt;/code&gt; in &lt;code&gt;.claude/settings.json&lt;/code&gt; or a &lt;code&gt;SessionStart&lt;/code&gt; hook that appends to &lt;code&gt;$CLAUDE_ENV_FILE&lt;/code&gt;, and remove production defaults from your scripts.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>cli</category>
    </item>
    <item>
      <title>Claude Code Token Usage: 97% of 44.6B Tokens Were Cache Re-Reads</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Wed, 04 Nov 2026 12:35:35 +0000</pubDate>
      <link>https://dev.to/ji_ai/claude-code-token-usage-97-of-446b-tokens-were-cache-re-reads-18ji</link>
      <guid>https://dev.to/ji_ai/claude-code-token-usage-97-of-446b-tokens-were-cache-re-reads-18ji</guid>
      <description>&lt;p&gt;Claude wrote 122 million tokens for me last month. To write them, it read 43 billion.&lt;/p&gt;

&lt;p&gt;I got that ratio by parsing 30 days of my Claude Code token usage straight from the JSONL session logs on my laptop: 192,178 API requests, 889 main sessions and 5,696 subagents. I wanted to know which habit was expensive. The answer was mostly not the one I would have bet on, and the fix I liked best turned out to be weak.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;97.03% of my 44.57B tokens were cache reads. Output was 0.27%.&lt;/li&gt;
&lt;li&gt;At API list prices the month comes to $15,290: 56.6% reads, 31.7% cache writes, 11.6% output. I'm on a subscription, so this is a measuring stick, not my bill.&lt;/li&gt;
&lt;li&gt;Subagents were 58% of the cost. Each one started with a median of 68,815 tokens before its first tool call.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Bash&lt;/code&gt; drove 67% of the re-reads I could pin on a tool. Claude ran &lt;code&gt;cat&lt;/code&gt;/&lt;code&gt;sed&lt;/code&gt;-style commands 42,453 times, more than the &lt;code&gt;Read&lt;/code&gt; tool's 29,968.&lt;/li&gt;
&lt;li&gt;Capping tool output at 5K tokens would save about 2.4%. Trimming 10K tokens from the startup context would save about 3.8%, with no change to any tool call.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where does Claude Code token usage actually go?
&lt;/h2&gt;

&lt;p&gt;Almost all of it goes to re-reading the conversation. Every agent step sends the whole context again, so a long session pays for its history on every request.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bucket&lt;/th&gt;
&lt;th&gt;Tokens&lt;/th&gt;
&lt;th&gt;Share of tokens&lt;/th&gt;
&lt;th&gt;List cost&lt;/th&gt;
&lt;th&gt;Share of cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cache reads&lt;/td&gt;
&lt;td&gt;43.24B&lt;/td&gt;
&lt;td&gt;97.03%&lt;/td&gt;
&lt;td&gt;$8,661&lt;/td&gt;
&lt;td&gt;56.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache writes&lt;/td&gt;
&lt;td&gt;1.20B&lt;/td&gt;
&lt;td&gt;2.70%&lt;/td&gt;
&lt;td&gt;$4,846&lt;/td&gt;
&lt;td&gt;31.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output&lt;/td&gt;
&lt;td&gt;122.3M&lt;/td&gt;
&lt;td&gt;0.27%&lt;/td&gt;
&lt;td&gt;$1,781&lt;/td&gt;
&lt;td&gt;11.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uncached input&lt;/td&gt;
&lt;td&gt;454K&lt;/td&gt;
&lt;td&gt;under 0.01%&lt;/td&gt;
&lt;td&gt;$2&lt;/td&gt;
&lt;td&gt;under 0.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Before anyone reads this as "caching is bad": the same 43.24B tokens at normal input prices would cost about $133,000. Caching cut that line by roughly 15x. It's the cheapest token I buy, and it's still the biggest line on the sheet.&lt;/p&gt;

&lt;p&gt;Size matters too. Requests above 200K tokens of context were 41.7% of all requests and 59.6% of the cost. My longest session ran 2,436 requests and took 2.3% of the month alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you measure Claude Code token usage from the logs?
&lt;/h2&gt;

&lt;p&gt;Read &lt;code&gt;~/.claude/projects/**/*.jsonl&lt;/code&gt; and sum &lt;code&gt;message.usage&lt;/code&gt;, but key it by &lt;code&gt;message.id&lt;/code&gt;. One API response is written as several JSONL lines, one per content block, and each line repeats the same usage object.&lt;/p&gt;

&lt;p&gt;That trap is big. My window had 400,880 assistant lines for 192,178 real requests. The naive sum said 93.0B tokens, 2.09x the true 44.6B. Resumed sessions add a smaller version of the same bug: 287 requests appeared in two files.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Counter&lt;/span&gt;

&lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;
&lt;span class="n"&gt;requests&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;  &lt;span class="c1"&gt;# message.id -&amp;gt; usage
&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expanduser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;~/.claude/projects/**/*.jsonl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recursive&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getmtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="s"&gt;usage&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;usage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;usage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# last line wins
&lt;/span&gt;
&lt;span class="n"&gt;totals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_creation_input_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_read_input_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This takes about 35 seconds here, against 21GB of logs. The mtime filter keeps whole files, so totals land slightly above a strict 30-day cut. For dollars, I priced each request by its own model: Opus 5.5 and Sonnet 5.5 cache reads are both $0.20 per million, and 5-minute and 1-hour writes are 1.25x and 2x input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does one tool result get billed dozens of times?
&lt;/h2&gt;

&lt;p&gt;A tool result enters the context once and is then re-read on every later request until the context resets. Think of it as rent, not a one-time fee.&lt;/p&gt;

&lt;p&gt;The median tool result in my logs was only 865 tokens, and it was re-read 23 times. Mean lifetime was 42.8 requests. The worst row was a 15,183-token MCP resource read that sat in a main session for 334 more requests: 5.07 million cache-read tokens, about $1, from a single call.&lt;/p&gt;

&lt;p&gt;Rent is concentrated. The top 1% of tool calls produced 17.4% of it, and the top 10% produced 58.4%.&lt;/p&gt;

&lt;p&gt;To estimate it, I took the context growth between consecutive requests, split it across that step's tool calls by character count, capped each at characters / 2.5, and multiplied by the requests left before the context dropped by more than 30%. It's an estimate, and a deliberately low one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Claude Code tool costs the most?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Bash&lt;/code&gt;, by a wide margin. It wasn't because each result was huge. There were just a lot of them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Calls&lt;/th&gt;
&lt;th&gt;Avg tokens added&lt;/th&gt;
&lt;th&gt;Est. re-read tokens&lt;/th&gt;
&lt;th&gt;Share of tool rent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Bash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;155,817&lt;/td&gt;
&lt;td&gt;1,459&lt;/td&gt;
&lt;td&gt;9.98B&lt;/td&gt;
&lt;td&gt;67.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;29,968&lt;/td&gt;
&lt;td&gt;1,956&lt;/td&gt;
&lt;td&gt;2.60B&lt;/td&gt;
&lt;td&gt;17.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP tools&lt;/td&gt;
&lt;td&gt;13,227&lt;/td&gt;
&lt;td&gt;877&lt;/td&gt;
&lt;td&gt;0.78B&lt;/td&gt;
&lt;td&gt;5.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Write&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,156&lt;/td&gt;
&lt;td&gt;2,906&lt;/td&gt;
&lt;td&gt;0.33B&lt;/td&gt;
&lt;td&gt;2.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The surprise was inside &lt;code&gt;Bash&lt;/code&gt;. I bucketed commands by their first verb, and file-reading verbs (&lt;code&gt;cat&lt;/code&gt;, &lt;code&gt;sed&lt;/code&gt;, &lt;code&gt;head&lt;/code&gt;, &lt;code&gt;tail&lt;/code&gt;, &lt;code&gt;awk&lt;/code&gt;, &lt;code&gt;nl&lt;/code&gt;, &lt;code&gt;wc&lt;/code&gt;) won: 42,453 calls and 4.22B re-read tokens. So the shell read files 1.4x as often as &lt;code&gt;Read&lt;/code&gt; did and generated 1.6x the rent.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Write&lt;/code&gt; is worth a look as well. The file body sits in the tool input, so a big generated file stays in context after it lands on disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much do Claude Code subagents cost before doing any work?
&lt;/h2&gt;

&lt;p&gt;About 19% of my month. The median subagent opened with 68,815 tokens of context and ran only 15 requests, so startup was a large part of its whole life.&lt;/p&gt;

&lt;p&gt;The math on all 5,696 subagents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First-request cache writes: $923. Only about 22% of first-request tokens were cache hits.&lt;/li&gt;
&lt;li&gt;Re-reading that startup context on later requests: $1,934.&lt;/li&gt;
&lt;li&gt;First-request cache reads: $17.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total: $2,874, or 18.8% of the month, spent on context that existed before any subagent ran a command. Main sessions added another $856 of startup re-reads. Across everything, startup context accounted for 32.2% of all cache reads. Tool results took 34.4%, and the other 33.4% is assistant text, injected reminders and whatever my low estimator declined to assign.&lt;/p&gt;

&lt;p&gt;My global &lt;code&gt;CLAUDE.md&lt;/code&gt; is 25KB. Every agent carries it, along with tool definitions and the skill list. That file felt free because I only ever wrote it once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is idle cache expiry the real cost problem?
&lt;/h2&gt;

&lt;p&gt;No. It's the obvious suspect, and it came in under 5%.&lt;/p&gt;

&lt;p&gt;I counted a cache rebuild as any request that kept at least 70% of the context but read less than half of it from cache. There were 649 of those, rewriting 183.7M tokens. That's 15.3% of all cache writes and roughly $740 at my average write price, or 4.8% of the month.&lt;/p&gt;

&lt;p&gt;Idle time doesn't even explain all of them. 257 rebuilds, the largest single group, happened less than 5 minutes after the previous request, and 61 lined up with a model switch. I didn't trace the rest. Worth fixing, but this isn't where the money is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What would actually reduce Claude Code token usage?
&lt;/h2&gt;

&lt;p&gt;Startup context first. I simulated three changes against the same 30 days, counting both re-reads and writes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Calls touched&lt;/th&gt;
&lt;th&gt;Simulated saving&lt;/th&gt;
&lt;th&gt;Share of month&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cap tool results at 5K tokens&lt;/td&gt;
&lt;td&gt;10,691 (4.55%)&lt;/td&gt;
&lt;td&gt;$369&lt;/td&gt;
&lt;td&gt;2.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trim 10K tokens from startup context&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;$582&lt;/td&gt;
&lt;td&gt;3.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cap tool results at 2K tokens&lt;/td&gt;
&lt;td&gt;43,924 (18.71%)&lt;/td&gt;
&lt;td&gt;$1,283&lt;/td&gt;
&lt;td&gt;8.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The output cap was the fix I wanted to win. It's easy to explain and easy to build. It lost to deleting 10K tokens of standing instructions, and it would truncate over 10,000 results to do it.&lt;/p&gt;

&lt;p&gt;The 2K cap looks best and is the one I trust least. Nearly one call in five would lose data, and an agent missing data runs another command. That's the part this simulation can't see.&lt;/p&gt;

&lt;p&gt;The caveats, stated plainly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This is one person's month, with a lot of automated subagent work. Your ratios will differ.&lt;/li&gt;
&lt;li&gt;Dollars are API list prices, not what I paid.&lt;/li&gt;
&lt;li&gt;Rent is an estimate, and a third of reads are unattributed.&lt;/li&gt;
&lt;li&gt;The savings are simulated. I did not A/B them, so treat them as a ranking, not a promise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So where does Claude Code token usage go? In my 30 days of logs, 97% of 44.6B tokens were cache re-reads of context the model had already seen, and output was 0.27%. Cost split 56.6% reads, 31.7% writes and 11.6% output, with subagents at 58% of the total. The biggest levers are how much context every agent starts with and how long sessions run past 200K tokens, ahead of output caps or idle cache expiry.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>python</category>
    </item>
    <item>
      <title>RoPE in bfloat16: 8,192 Token Positions Collapsed Into 897</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Wed, 04 Nov 2026 00:28:57 +0000</pubDate>
      <link>https://dev.to/ji_ai/rope-in-bfloat16-8192-token-positions-collapsed-into-897-51pm</link>
      <guid>https://dev.to/ji_ai/rope-in-bfloat16-8192-token-positions-collapsed-into-897-51pm</guid>
      <description>&lt;p&gt;Cast the integers 8,190 through 8,200 to bfloat16 and you get eleven copies of 8,192.&lt;/p&gt;

&lt;p&gt;That one line is why &lt;strong&gt;RoPE in bfloat16&lt;/strong&gt; is a trap. Rotary position embeddings tell attention where each token sits, and the number format most LLMs run in can't count past 256 without rounding. There's no NaN and no crash. A 128-token unit test comes back bit-identical. I kept seeing "force float32" around rotary code, so I built a small NumPy simulator to find out how bad the damage really is.&lt;/p&gt;

&lt;p&gt;Across an 8,192-token sequence, only 897 distinct positions survived. A head whose entire job is "look at the previous token" dropped from 100% to 3.5%.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;bfloat16 has 8 bits of significand precision, so integers are exact only up to 256. Between 4,096 and 8,192, representable values sit 32 apart.&lt;/li&gt;
&lt;li&gt;Casting &lt;code&gt;position_ids&lt;/code&gt; to bfloat16 turns 8,192 positions into 897 distinct values. 89.1% of adjacent token pairs end up at distance zero.&lt;/li&gt;
&lt;li&gt;In a synthetic previous-token head, top-1 accuracy fell from 8,191 of 8,191 queries to 286 (3.5%).&lt;/li&gt;
&lt;li&gt;Rounding the final cos/sin tables, q and k to bfloat16 cost nothing measurable: still 8,191 of 8,191. Only the steps before &lt;code&gt;cos&lt;/code&gt; and &lt;code&gt;sin&lt;/code&gt; need float32.&lt;/li&gt;
&lt;li&gt;Fix: keep &lt;code&gt;inv_freq&lt;/code&gt;, positions and angles in float32, disable autocast around them, then cast cos/sin to the model dtype.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does RoPE in bfloat16 actually break?
&lt;/h2&gt;

&lt;p&gt;RoPE in bfloat16 breaks position information once a sequence passes 256 tokens, while every short test keeps passing. My setup used head_dim 128 and theta 10,000, with a synthetic attention head built so the correct answer is always token &lt;code&gt;i - 1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I tested all 8,191 query positions with 3 random heads. A hit means the previous token strictly beat every other key, so ties count as misses.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query position&lt;/th&gt;
&lt;th&gt;Hit rate with &lt;code&gt;position_ids&lt;/code&gt; in bfloat16&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 to 255&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;256 to 511&lt;/td&gt;
&lt;td&gt;12.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;512 to 1,023&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,024 to 8,191&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Your tests live in the first row. Then it falls off a cliff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why can't bfloat16 count past 256?
&lt;/h2&gt;

&lt;p&gt;bfloat16 keeps float32's 8 exponent bits but only 7 stored mantissa bits, which gives 8 bits of precision with the implicit leading bit. The gap between neighboring values doubles at every power of two.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Value range&lt;/th&gt;
&lt;th&gt;Gap between bfloat16 values&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0 to 256&lt;/td&gt;
&lt;td&gt;1 or smaller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;256 to 512&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4,096 to 8,192&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8,192 to 16,384&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first integer that can't be stored is 257, which rounds to 256. By 131,072 tokens you have 1,409 distinct positions and a worst-case error of 256.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a rounded position wreck attention?
&lt;/h2&gt;

&lt;p&gt;RoPE works because the attention score between two tokens depends only on their distance. It rotates q and k by angles proportional to position, and when you take the dot product, the absolute positions cancel and &lt;code&gt;i - j&lt;/code&gt; remains.&lt;/p&gt;

&lt;p&gt;Rounding destroys that cancellation, because &lt;code&gt;round(i) - round(j)&lt;/code&gt; isn't &lt;code&gt;i - j&lt;/code&gt;. Here's what happened to adjacent tokens in my 8,192-token run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;89.1% of neighbors had a distance of exactly 0&lt;/li&gt;
&lt;li&gt;3.1% kept the correct distance of 1&lt;/li&gt;
&lt;li&gt;The rest jumped, up to a gap of 32&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So "the previous token" and "this token" are usually the same place. Attention can't separate keys sitting on one rounded position, and a whole block of 32 tokens gets identical rotation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which bfloat16 cast in RoPE actually hurts?
&lt;/h2&gt;

&lt;p&gt;The casts before the trig functions hurt and the casts after them don't. I put bfloat16 rounding at one stage at a time and averaged 3 random heads. Attention weight is on the correct token, using a softmax sharpness I picked, so compare those rows to each other and ignore the absolute value.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What's in bfloat16&lt;/th&gt;
&lt;th&gt;Hits of 8,191&lt;/th&gt;
&lt;th&gt;Hit rate&lt;/th&gt;
&lt;th&gt;Attention on right token&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;td&gt;8,191&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;0.201&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cos/sin tables, q and k&lt;/td&gt;
&lt;td&gt;8,191&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;0.201&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;inv_freq&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;8,191&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;0.201&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Angles (&lt;code&gt;pos * inv_freq&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;td&gt;6.2%&lt;/td&gt;
&lt;td&gt;0.110&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;position_ids&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;286&lt;/td&gt;
&lt;td&gt;3.5%&lt;/td&gt;
&lt;td&gt;0.098&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Autocast style: inputs and angles&lt;/td&gt;
&lt;td&gt;267&lt;/td&gt;
&lt;td&gt;3.3%&lt;/td&gt;
&lt;td&gt;0.094&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A cos value lives between -1 and 1, where bfloat16's relative error is harmless. An angle lives in the thousands of radians, and at 8,000 radians a gap of 32 is about five full rotations. The output of &lt;code&gt;cos&lt;/code&gt; tolerates rounding. Its input doesn't.&lt;/p&gt;

&lt;p&gt;A sampled run at Llama 3's &lt;code&gt;rope_theta&lt;/code&gt; of 500,000 showed the same collapse, so a larger base doesn't rescue you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is inv_freq in bfloat16 harder to catch?
&lt;/h2&gt;

&lt;p&gt;An &lt;code&gt;inv_freq&lt;/code&gt; table in bfloat16 keeps RoPE's relative property, so nearby attention looks perfect while long-range attention drifts. That 100% row above is misleading.&lt;/p&gt;

&lt;p&gt;Each frequency is off by up to 0.37%, the same at every position. Distance still cancels cleanly, but the model now rotates at frequencies it wasn't trained on, and the phase error grows with distance: 0.078 radians at 64 tokens, 1.25 at 1,024, 5.01 at 4,096.&lt;/p&gt;

&lt;p&gt;I tested heads aimed at a fixed offset, using 500 random heads per distance:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target offset&lt;/th&gt;
&lt;th&gt;float32 hits&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;inv_freq&lt;/code&gt; bfloat16 hits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;64 to 512&lt;/td&gt;
&lt;td&gt;500 of 500&lt;/td&gt;
&lt;td&gt;500 of 500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,024&lt;/td&gt;
&lt;td&gt;500 of 500&lt;/td&gt;
&lt;td&gt;439 of 500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2,048&lt;/td&gt;
&lt;td&gt;500 of 500&lt;/td&gt;
&lt;td&gt;286 of 500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4,096&lt;/td&gt;
&lt;td&gt;500 of 500&lt;/td&gt;
&lt;td&gt;232 of 500&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is the nastiest variant. Local grammar stays fine and only the far end of your context gets quietly worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does this bug get into real code?
&lt;/h2&gt;

&lt;p&gt;It usually gets in through three ordinary lines, and none of them look like precision decisions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dtype borrowed from the activations:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# fine for x in float32, broken past 256 when x is bfloat16
&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seq_len&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;freqs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inv_freq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A buffer that gets downcast with the model.&lt;/strong&gt; &lt;code&gt;model.to(torch.bfloat16)&lt;/code&gt; casts floating-point buffers too, so a registered &lt;code&gt;inv_freq&lt;/code&gt; is rounded on the spot. Calling &lt;code&gt;.float()&lt;/code&gt; on it later changes the dtype and can't bring the lost bits back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Autocast.&lt;/strong&gt; If you build angles with a matmul of &lt;code&gt;inv_freq&lt;/code&gt; and positions, autocast can run it in bfloat16. Hugging Face's Llama rotary code disables autocast around this exact computation, with a "Force float32" comment. Unsloth's early-2024 Gemma bug writeup also listed RoPE needing float32 instead of bfloat16.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you fix RoPE precision in PyTorch?
&lt;/h2&gt;

&lt;p&gt;Compute everything up to &lt;code&gt;cos&lt;/code&gt; and &lt;code&gt;sin&lt;/code&gt; in float32 with autocast off, and cast only the result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rope_cos_sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;position_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;head_dim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;theta&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;10000.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out_dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bfloat16&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;position_ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;autocast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;exps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;head_dim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;head_dim&lt;/span&gt;
        &lt;span class="n"&gt;inv_freq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;theta&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;exps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;angles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;position_ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;)[...,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;inv_freq&lt;/span&gt;
        &lt;span class="n"&gt;angles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;angles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;angles&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;angles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out_dtype&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;angles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out_dtype&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;inv_freq&lt;/code&gt; is rebuilt inside the function on purpose, so &lt;code&gt;model.to()&lt;/code&gt; has no buffer to round.&lt;/p&gt;

&lt;p&gt;Then add a test long enough to fail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_rope_positions_stay_distinct&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rope_cos_sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;head_dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_inv_freq_stays_float32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;named_buffers&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inv_freq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dtype&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the simulator, that row count gave 8,192 for the correct path, 897 for bfloat16 positions and 7,533 for bfloat16 angles. It can't see the &lt;code&gt;inv_freq&lt;/code&gt; drift, which is why the second test exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is float16 safer for RoPE than bfloat16?
&lt;/h2&gt;

&lt;p&gt;float16 is more precise and fails louder, and it's still the wrong choice. Its integers are exact up to 2,048, it keeps 4,097 distinct positions out of 8,192, and at 65,520 a position overflows to infinity. &lt;code&gt;cos(inf)&lt;/code&gt; is NaN, so at least you'd notice.&lt;/p&gt;

&lt;p&gt;bfloat16 has float32's range, so it never overflows. It just keeps rounding, and that's what makes it the quieter bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did this test not measure?
&lt;/h2&gt;

&lt;p&gt;This is a synthetic attention head in NumPy, with bfloat16 emulated by round-to-nearest-even on float32. I didn't run a trained model. Real networks spread position across many heads and lean on content too, so don't read 3.5% as a prediction for your model's accuracy. The PyTorch snippets above are the translation of that logic and weren't what produced these numbers.&lt;/p&gt;

&lt;p&gt;To check a real model, run one long prompt through your RoPE path and through a float32 reference, then compare the logits at positions past 1,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why does RoPE in bfloat16 collapse token positions?
&lt;/h2&gt;

&lt;p&gt;RoPE in bfloat16 collapses token positions because bfloat16 has 8 bits of precision, so integers above 256 round to the nearest representable value, and the gap reaches 32 between 4,096 and 8,192. That left 897 distinct positions out of 8,192 in my test, and it broke the &lt;code&gt;i - j&lt;/code&gt; cancellation RoPE depends on, taking a synthetic previous-token head from 100% to 3.5%. Keep &lt;code&gt;inv_freq&lt;/code&gt;, positions and angles in float32 with autocast off, and cast only the final cos/sin tables to bfloat16.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>machinelearning</category>
      <category>python</category>
      <category>performance</category>
    </item>
    <item>
      <title>Shipping a Mac Electron App Without Apple Notarization</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Tue, 03 Nov 2026 20:15:17 +0000</pubDate>
      <link>https://dev.to/ji_ai/shipping-a-mac-electron-app-without-apple-notarization-3obp</link>
      <guid>https://dev.to/ji_ai/shipping-a-mac-electron-app-without-apple-notarization-3obp</guid>
      <description>&lt;p&gt;Every new version of Refmade asks every Mac user to click "Open Anyway" in System Settings again. I chose that. The macOS signature check on the app reports one fatal finding, a missing notarization, and I decided to leave it there.&lt;/p&gt;

&lt;p&gt;Refmade is a desktop app that wraps the Claude Code and Codex command-line tools and shows their work as an office floor. It is an independent app, not affiliated with Anthropic or OpenAI. The UI is Korean right now; that changes later in this series. Claude Code and Codex agents wrote most of the code at my direction. The decisions in this post are mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the update notice does
&lt;/h2&gt;

&lt;p&gt;The app does not update itself. About ten seconds after launch, and then once a day, it makes one GET request to a fixed address and reads a version number from the JSON that comes back. If the number is newer than its own, it shows a notice with a button that opens the download page in your browser. You can turn the check off from the menu.&lt;/p&gt;

&lt;p&gt;The check is written to learn as little as possible about you and to trust the answer as little as possible. No app version, no ID and no cookie go out (the server sees your IP address, like any visit). The request has an 8-second limit for the whole answer, reads at most 16 KB, and follows no redirects. The app never opens or downloads an address that came from the answer, so a wrong answer can only produce a wrong number.&lt;/p&gt;

&lt;p&gt;The answer can list a version per platform, and that matters for the Windows build later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;latestVersionOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;platformKey&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;platforms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platforms&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platforms&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platforms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platforms&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;platforms&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasOwn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;platforms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseVersion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;platforms&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;platforms&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;platforms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// a list without this platform: nothing for it yet&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseVersion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On October 1 I saw it work for real. After I published 0.3.1, an installed 0.3.0 app picked it up as available from the production file.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe3ja3rw1gpu48j4hiyyx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe3ja3rw1gpu48j4hiyyx.png" alt="A site update-log card titled Mac 0.3.9 dated October 10, 2026, with three change notes and a button to get the latest version" width="800" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The site's update log for Mac 0.3.9, October 10, 2026 (Korean). Headline: "When it needs my answer, the letters bounce." Three notes: my turn gets a color and two bounces, working gets a wave, news gets one bounce. Below: "This update is for Mac. The public Windows version is 0.3.2." Button: "Get latest version". The decision card on the right is public demo data.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why notify and not auto-update
&lt;/h2&gt;

&lt;p&gt;I decided on September 30 that a notice is enough for the first public version. I did not write down a motive beyond that, so here is the effect of the design. With an auto-updater, my server's reply would decide what runs on your machine. With a notice, the reply is one number, which the app can check against its own.&lt;/p&gt;

&lt;p&gt;Installers are never overwritten. When I changed things after 0.3.5 was published, I shipped 0.3.6 instead of replacing the 0.3.5 file. Each build has a versioned file name and is served with immutable cache headers. Only the current version stays downloadable, so an old link returns 404. That is a real cost for anyone who bookmarked it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three ways to ship on a Mac
&lt;/h2&gt;

&lt;p&gt;On October 1 I decided between these three.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What users see&lt;/th&gt;
&lt;th&gt;What it needs&lt;/th&gt;
&lt;th&gt;My verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Notarized&lt;/td&gt;
&lt;td&gt;Opens normally&lt;/td&gt;
&lt;td&gt;Paid Apple Developer membership, notarization step per build&lt;/td&gt;
&lt;td&gt;Not now&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ad-hoc signed, not notarized&lt;/td&gt;
&lt;td&gt;"Open Anyway" once per version&lt;/td&gt;
&lt;td&gt;Nothing paid (&lt;code&gt;identity: "-"&lt;/code&gt; in electron-builder)&lt;/td&gt;
&lt;td&gt;Chosen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mac App Store&lt;/td&gt;
&lt;td&gt;Store install&lt;/td&gt;
&lt;td&gt;Sandbox, store review&lt;/td&gt;
&lt;td&gt;Doesn't fit, see below&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ad-hoc signing seals the bundle (1,042 files in 0.2.3) so macOS can tell it was not modified, but it does not identify a developer. After it, &lt;code&gt;syspolicy_check&lt;/code&gt; dropped from three fatal findings to one. The one left is the missing notarization.&lt;/p&gt;

&lt;p&gt;The download page says plainly why the warning appears and where to click. The page explains that macOS blocks the first launch because the app has no Apple notarization, that it is not a malfunction, and which two clicks to make. It also repeats that the step comes back with every new version, so nobody is surprised by it after an update. Windows has the same shape: an unsigned installer, a "More info" link and a Run button, once per version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the App Store is out
&lt;/h2&gt;

&lt;p&gt;This is my reading of Apple's &lt;a href="https://developer.apple.com/app-store/review/guidelines/" rel="noopener noreferrer"&gt;App Review Guidelines&lt;/a&gt;, not an official ruling. Guideline 2.5.2 says apps "may not read or write data outside the designated container area" and may not "execute code which introduces or changes features or functionality of the app". Refmade reads session logs that Claude Code and Codex keep in the user's home folder, and it launches those tools. 2.4.5(i) also requires Mac App Store apps to be sandboxed. Both go against what the app is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Windows installer without Rosetta
&lt;/h2&gt;

&lt;p&gt;I build on a Mac that runs macOS 27.0.1 and has no Rosetta. When I first tried a Windows installer there (for 0.2.2), electron-builder's NSIS step spawned &lt;code&gt;makensis&lt;/code&gt;, an x86_64 binary, and failed with &lt;code&gt;spawn -86&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The workaround was a flag that pulls a native NSIS toolset, &lt;code&gt;-c.toolsets.nsis=1.2.1&lt;/code&gt;, passed to &lt;code&gt;electron-builder --win nsis --x64&lt;/code&gt;. That produced &lt;code&gt;Refmade-0.3.1-x64.exe&lt;/code&gt;, 133,331,616 bytes, with no code signature. Up to that point the file had never run on Windows. A 15-minute checklist was run on a real Windows PC. It passed, and only then did I publish 0.3.1.&lt;/p&gt;

&lt;p&gt;Windows is still behind. The public Windows build is 0.3.2. The Mac build is 0.3.9. A 0.3.8 Windows installer exists, cross-built, but I did not publish it because I could not run it. That is what the per-platform list in &lt;code&gt;latest.json&lt;/code&gt; is for. Windows users are not told about 0.3.9, because there is nothing for them to get.&lt;/p&gt;

&lt;h2&gt;
  
  
  The yellow stripe
&lt;/h2&gt;

&lt;p&gt;Version 0.3.1 also fixed a visual bug. The game theme drew a yellow stripe along the left edge of every card title. The cause was a 12×7 pixel PNG, &lt;code&gt;frame-title&lt;/code&gt;, whose left 9 pixels are stretched as a slice. A gold jewel sat inside those 9 pixels and got stretched to the card's height.&lt;/p&gt;

&lt;p&gt;I asked for the stripe to go. The fix is a script, &lt;code&gt;drop_title_jewel.py&lt;/code&gt;, that erases the jewel from four images and from two CSS data URIs. A test, &lt;code&gt;game-art.test.mjs&lt;/code&gt;, now decodes the PNGs directly and counts gold pixels. The expected count is zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A notice that reads one number is a smaller promise than an updater. I can keep it.&lt;/li&gt;
&lt;li&gt;Never overwrite a published installer. A new number costs nothing.&lt;/li&gt;
&lt;li&gt;Say which platform got tested and on which version.&lt;/li&gt;
&lt;li&gt;Read the store rules before building toward a store. Two guidelines were enough to rule it out for me.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app link comes in the last post of the series.&lt;/p&gt;

&lt;p&gt;If you ship an unsigned or ad-hoc signed desktop app, what did you put on the download page to make the "Open Anyway" step less scary?&lt;/p&gt;

</description>
      <category>electron</category>
      <category>macos</category>
      <category>buildinpublic</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>json.dumps Writes NaN by Default: 31 LLM Reports JSON.parse Rejected</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Tue, 03 Nov 2026 12:22:33 +0000</pubDate>
      <link>https://dev.to/ji_ai/jsondumps-writes-nan-by-default-31-llm-reports-jsonparse-rejected-2hnk</link>
      <guid>https://dev.to/ji_ai/jsondumps-writes-nan-by-default-31-llm-reports-jsonparse-rejected-2hnk</guid>
      <description>&lt;p&gt;Nine people emailed me the same screenshot: a spinner and the line "Your report is being prepared." One of them had been looking at it for 51 hours.&lt;/p&gt;

&lt;p&gt;The report was done. It had been done since about 40 seconds after their session ended. It was sitting in object storage as a perfectly normal-looking file. The problem was three characters inside it: &lt;code&gt;NaN&lt;/code&gt;. &lt;strong&gt;Python json.dumps writes NaN by default&lt;/strong&gt;, the browser's &lt;code&gt;JSON.parse&lt;/code&gt; refuses to read it, and my pipeline had no step anywhere that cared about the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Python's &lt;code&gt;json.dumps&lt;/code&gt; has &lt;code&gt;allow_nan=True&lt;/code&gt; by default, so &lt;code&gt;float('nan')&lt;/code&gt; becomes a bare &lt;code&gt;NaN&lt;/code&gt; token. That is not valid JSON.&lt;/li&gt;
&lt;li&gt;Python's own &lt;code&gt;json.loads&lt;/code&gt; reads &lt;code&gt;NaN&lt;/code&gt; back without complaint, so round-trip tests pass. JavaScript's &lt;code&gt;JSON.parse&lt;/code&gt; throws a &lt;code&gt;SyntaxError&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;My NaN came from &lt;code&gt;np.nanmean&lt;/code&gt; over a rubric category where the LLM scorer returned &lt;code&gt;null&lt;/code&gt; for every answer. An all-NaN slice returns NaN and only emits a &lt;code&gt;RuntimeWarning&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;31 of 1,940 reports (1.6%) over 16 days were unreadable. 26 of the 31 came from one interviewer style.&lt;/li&gt;
&lt;li&gt;Fix: score fields as &lt;code&gt;Optional[float]&lt;/code&gt; with &lt;code&gt;allow_inf_nan=False&lt;/code&gt; in Pydantic, plus &lt;code&gt;json.dumps(..., allow_nan=False)&lt;/code&gt; as a tripwire at every write.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What broke?
&lt;/h2&gt;

&lt;p&gt;The system is Preterview, an interview practice platform I run (full disclosure: I built it, &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;preterview.com/en&lt;/a&gt;). A candidate does a voice interview with one of three interviewer styles, an LLM scores each answer against a rubric, and a worker assembles everything into a written report.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftd5zed80xfjpv7qjalsn.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftd5zed80xfjpv7qjalsn.jpg" alt="Preterview — an interview session in progress" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The report pipeline looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The LLM scores each answer per rubric category (communication, technical depth, structure, and so on) and returns structured JSON.&lt;/li&gt;
&lt;li&gt;A Python worker aggregates scores per category.&lt;/li&gt;
&lt;li&gt;The worker writes &lt;code&gt;report.json&lt;/code&gt; to object storage.&lt;/li&gt;
&lt;li&gt;The frontend fetches that file directly and renders it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 is where 31 reports died. The fetch succeeded, &lt;code&gt;JSON.parse&lt;/code&gt; threw, my error handler caught it, and the error handler's fallback was "show the preparing state and poll again." Forever. I had built a very polite infinite loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where did the NaN come from?
&lt;/h2&gt;

&lt;p&gt;The NaN came from averaging a category that had no scores at all. The scorer prompt allows the model to say "I can't assess this category from this answer" by returning &lt;code&gt;null&lt;/code&gt;. That's deliberate. I'd rather get a &lt;code&gt;null&lt;/code&gt; than a confident 6/10 for system design on an answer about a team conflict.&lt;/p&gt;

&lt;p&gt;The aggregation code turned those nulls into NaN on purpose so they'd be skipped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;category_mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;arr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nan&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nanmean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;np.nanmean&lt;/code&gt; ignores NaN values. That's what I wanted. What I hadn't thought through is what it does when &lt;em&gt;every&lt;/em&gt; value is NaN:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nanmean&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nan&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nb"&gt;RuntimeWarning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mean&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;empty&lt;/span&gt; &lt;span class="nb"&gt;slice&lt;/span&gt;
&lt;span class="n"&gt;nan&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No exception. A warning and a NaN.&lt;/p&gt;

&lt;p&gt;When does every answer in a category get &lt;code&gt;null&lt;/code&gt;? When the interviewer never asks a question that touches that category. One of my three interviewer styles is a conversational, culture-fit style. It often runs a whole session without a single technical question. So the "technical depth" category came back all-null, &lt;code&gt;nanmean&lt;/code&gt; returned NaN, and the NaN flowed downstream. That's why 26 of the 31 broken reports came from that one style.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does Python json.dumps write NaN by default?
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;allow_nan&lt;/code&gt; defaults to &lt;code&gt;True&lt;/code&gt;. The Python docs are upfront about it: with &lt;code&gt;allow_nan=True&lt;/code&gt;, out-of-range floats are written as their JavaScript equivalents (&lt;code&gt;NaN&lt;/code&gt;, &lt;code&gt;Infinity&lt;/code&gt;, &lt;code&gt;-Infinity&lt;/code&gt;), and the docs note this is not strictly JSON compliant. RFC 8259 has no representation for NaN at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;technical_depth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;technical_depth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: NaN}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The part that let this survive testing: Python reads it back just fine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;technical_depth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: NaN}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;technical_depth&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;nan&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My test suite had a round-trip test: build a report, dump it, load it, compare. Python on both ends, so it passed. The only consumer that enforced the actual JSON spec was the browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"technical_depth": NaN}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// SyntaxError: Unexpected token 'N'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why didn't my logs show anything?
&lt;/h2&gt;

&lt;p&gt;Two reasons, and both are defaults.&lt;/p&gt;

&lt;p&gt;First, Python's default warnings filter shows a given warning once per code location, not once per occurrence. My workers are long-running processes. The first all-null category after a deploy printed &lt;code&gt;Mean of empty slice&lt;/code&gt; to stderr. The next 30 printed nothing. Across 16 days I found 11 instances of that warning in the logs, one per worker start, sitting between thousands of normal lines. Nothing about it said "a user's report is broken."&lt;/p&gt;

&lt;p&gt;Second, the worker considered the job successful. &lt;code&gt;json.dumps&lt;/code&gt; returned a string, the upload returned 200, the job was marked done. From the backend's point of view, every one of the 1,940 reports succeeded.&lt;/p&gt;

&lt;p&gt;There was one more trap I tripped over while debugging. My first attempt to display the score did this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cannot&lt;/span&gt; &lt;span class="n"&gt;convert&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="n"&gt;NaN&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;integer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But &lt;code&gt;round(float("nan"), 1)&lt;/code&gt; quietly returns &lt;code&gt;nan&lt;/code&gt;. The report used one decimal place, so this path never raised either. Every layer had a way to pass NaN along without anyone noticing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which serializers accept NaN and which reject it?
&lt;/h2&gt;

&lt;p&gt;This is the table I wish I'd had. Same input, &lt;code&gt;float("nan")&lt;/code&gt;, very different behavior:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;json.dumps&lt;/code&gt; (stdlib)&lt;/td&gt;
&lt;td&gt;Writes &lt;code&gt;NaN&lt;/code&gt;. Invalid JSON, no error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;json.dumps(..., allow_nan=False)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Raises &lt;code&gt;ValueError&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;json.loads&lt;/code&gt; (stdlib)&lt;/td&gt;
&lt;td&gt;Accepts &lt;code&gt;NaN&lt;/code&gt;, returns &lt;code&gt;nan&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Starlette / FastAPI &lt;code&gt;JSONResponse&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Raises &lt;code&gt;ValueError&lt;/code&gt; (it uses &lt;code&gt;allow_nan=False&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pydantic v2 &lt;code&gt;model_dump_json()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Writes &lt;code&gt;null&lt;/code&gt; by default (&lt;code&gt;ser_json_inf_nan&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;orjson&lt;/td&gt;
&lt;td&gt;Writes &lt;code&gt;null&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres &lt;code&gt;jsonb&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Rejects the input as invalid JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser &lt;code&gt;JSON.parse&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Throws &lt;code&gt;SyntaxError&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The irony: if this report had gone through my FastAPI endpoint, it would have been a loud 500 on day one. If I'd stored it in a &lt;code&gt;jsonb&lt;/code&gt; column, the insert would have failed. I chose to write it straight to object storage with the stdlib serializer, which happens to be the most permissive path in the whole table.&lt;/p&gt;

&lt;p&gt;Also notice that "writes &lt;code&gt;null&lt;/code&gt;" is not automatically safe. Pydantic and orjson would have produced valid JSON, but the frontend would then have rendered "Technical depth: null" or crashed on &lt;code&gt;null.toFixed(1)&lt;/code&gt;. Valid syntax, wrong meaning.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you stop NaN from reaching your JSON?
&lt;/h2&gt;

&lt;p&gt;Make "not assessed" a real value instead of a float that pretends to be a number. Then make every serializer refuse NaN so the next mistake fails at write time, not in a user's browser.&lt;/p&gt;

&lt;p&gt;The model change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CategoryScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allow_inf_nan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;assessed_answers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the aggregation stops producing NaN in the first place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;category_mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;vals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;vals&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No numpy, no warning, and the empty case is a branch you can read instead of a side effect you have to remember.&lt;/p&gt;

&lt;p&gt;Then the tripwire, at the single function that writes report files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;allow_nan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If anything upstream ever sneaks a NaN or an Infinity back in, this raises, the job fails, and my alerting sees a failed job instead of a successful one.&lt;/p&gt;

&lt;p&gt;Two smaller changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tests run with warnings as errors.&lt;/strong&gt; &lt;code&gt;filterwarnings = error::RuntimeWarning&lt;/code&gt; in pytest config. The "Mean of empty slice" warning would have failed CI on the first test with a skipped category, and I had one. It just never asserted anything about the output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The frontend stops polling on parse errors.&lt;/strong&gt; A &lt;code&gt;SyntaxError&lt;/code&gt; is not "still preparing." It now shows an error state and pings me.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The frontend change was really the bigger lesson. The NaN was a bug. The spinner turned that bug into 51 hours of a user waiting for something that was never going to arrive.&lt;/p&gt;

&lt;h2&gt;
  
  
  How did I recover the 31 broken reports?
&lt;/h2&gt;

&lt;p&gt;I re-ran aggregation only. The raw per-answer LLM scores were stored separately from the assembled report, so I didn't need a single new model call. All 31 reports rebuilt and rendered in under two minutes, with "Technical depth: not assessed in this session" where the NaN used to be. I emailed the nine people who'd written in, plus the other 22 who'd quietly given up.&lt;/p&gt;

&lt;p&gt;If I'd only stored the final report, I'd have had to re-score 31 sessions and explain why their scores changed. Keep your raw LLM outputs. You will need to re-derive something from them eventually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does Python json.dumps write NaN by default?
&lt;/h2&gt;

&lt;p&gt;Yes. Python's &lt;code&gt;json.dumps&lt;/code&gt; uses &lt;code&gt;allow_nan=True&lt;/code&gt; by default, so &lt;code&gt;float('nan')&lt;/code&gt; is written as a bare &lt;code&gt;NaN&lt;/code&gt; token, which is not valid JSON under RFC 8259. Python's &lt;code&gt;json.loads&lt;/code&gt; accepts it back, so Python-only tests won't catch it, but JavaScript's &lt;code&gt;JSON.parse&lt;/code&gt;, Postgres &lt;code&gt;jsonb&lt;/code&gt;, and most strict parsers will reject the file. Pass &lt;code&gt;allow_nan=False&lt;/code&gt; to make serialization raise a &lt;code&gt;ValueError&lt;/code&gt; instead, and represent missing numeric values as &lt;code&gt;None&lt;/code&gt;/&lt;code&gt;null&lt;/code&gt; explicitly, especially for LLM-generated scores where "couldn't assess" is a legitimate answer that averaging code will happily turn into NaN.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>llm</category>
      <category>ai</category>
    </item>
    <item>
      <title>asyncpg + PgBouncer Transaction Mode: 61 Prepared Statement Errors</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Tue, 03 Nov 2026 00:19:58 +0000</pubDate>
      <link>https://dev.to/ji_ai/asyncpg-pgbouncer-transaction-mode-61-prepared-statement-errors-2pin</link>
      <guid>https://dev.to/ji_ai/asyncpg-pgbouncer-transaction-mode-61-prepared-statement-errors-2pin</guid>
      <description>&lt;p&gt;On a weeknight around 9:40, fourteen people finished a session in my app inside the same three minutes. Three of them never got their report. The logs had the same line over and over: &lt;code&gt;prepared statement "__asyncpg_stmt_1a__" already exists&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I had added PgBouncer two days earlier to fix a connection limit problem. It fixed that one and introduced a new one. If you run asyncpg + PgBouncer transaction mode, this post is the autopsy I wish I had read first: why it fails only under load, why the obvious fix only gets you 85% of the way, and what actually got me to zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;asyncpg uses named prepared statements&lt;/strong&gt; (&lt;code&gt;__asyncpg_stmt_N__&lt;/code&gt;). Those live on one Postgres server connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PgBouncer transaction mode&lt;/strong&gt; hands your next transaction to whichever server connection is free. Your statement name is now on the wrong connection, or collides with someone else's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;statement_cache_size=0&lt;/code&gt; alone was not enough&lt;/strong&gt; in my SQLAlchemy setup. Failures dropped from 61 to 9, not to 0, because SQLAlchemy's asyncpg dialect still calls &lt;code&gt;prepare()&lt;/code&gt; with sequential names.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fix that worked:&lt;/strong&gt; disable both caches &lt;em&gt;and&lt;/em&gt; give every prepared statement a unique name with &lt;code&gt;prepared_statement_name_func&lt;/code&gt;. Zero errors across the next 11,800 writes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; p50 query time went from 3.1 ms to 3.6 ms because Postgres now re-parses every statement. I'll take it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What was the setup?
&lt;/h2&gt;

&lt;p&gt;The backend is FastAPI, SQLAlchemy 2.x async, and asyncpg, talking to a managed Postgres instance with a hard cap of 100 connections. Each app worker held its own SQLAlchemy pool. Scale out to enough workers and the pools alone eat the cap, so I put PgBouncer in front in &lt;code&gt;pool_mode = transaction&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The system is Preterview, an interview practice app I run (full disclosure: I built it). A session is a live voice conversation, and when it ends the backend writes the transcript, per-question scores, and a written report in one burst of inserts. That burst is the write path that broke.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftd5zed80xfjpv7qjalsn.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftd5zed80xfjpv7qjalsn.jpg" alt="Preterview — an interview session in progress" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Locally, everything passed. Staging passed. Production at quiet hours passed. The failures only showed up when a dozen sessions ended at once, which is exactly the moment you can't debug interactively.&lt;/p&gt;

&lt;h2&gt;
  
  
  How bad was it?
&lt;/h2&gt;

&lt;p&gt;In the first 48 hours behind PgBouncer, out of 2,140 session-end writes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Error&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DuplicatePreparedStatementError: ... already exists&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;InvalidSQLStatementNameError: ... does not exist&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total failed writes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;61 (2.9%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every single failure happened when concurrent session-end writes were above roughly 20 in flight. Below that, zero. That pattern is the whole clue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does asyncpg break behind PgBouncer transaction mode?
&lt;/h2&gt;

&lt;p&gt;asyncpg breaks behind PgBouncer transaction mode because it prepares statements by name on a server connection, and PgBouncer doesn't promise you the same server connection twice. The statement name is client-side state pointing at server-side memory that may not be yours anymore.&lt;/p&gt;

&lt;p&gt;Here's the sequence in slow motion:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Worker A runs &lt;code&gt;INSERT INTO reports ...&lt;/code&gt;. asyncpg sends &lt;code&gt;PARSE&lt;/code&gt; with the name &lt;code&gt;__asyncpg_stmt_1a__&lt;/code&gt; on server connection S1. Postgres stores it on S1.&lt;/li&gt;
&lt;li&gt;The transaction commits. PgBouncer returns S1 to its pool.&lt;/li&gt;
&lt;li&gt;Worker A's next transaction gets server connection S2. asyncpg's cache says "I already prepared this as &lt;code&gt;__asyncpg_stmt_1a__&lt;/code&gt;", so it sends &lt;code&gt;BIND&lt;/code&gt; with that name. S2 has never heard of it. &lt;strong&gt;"does not exist."&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Meanwhile Worker B, on its own fresh client connection, also counts from the same starting point. It lands on S1 and tries to &lt;code&gt;PARSE&lt;/code&gt; a different query as &lt;code&gt;__asyncpg_stmt_1a__&lt;/code&gt;. S1 already has one. &lt;strong&gt;"already exists."&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With low concurrency, PgBouncer tends to hand back the same idle server connection, so the names line up by luck. Under load, connections get shuffled and the luck runs out. That's why it looked like a flaky bug instead of a configuration bug.&lt;/p&gt;

&lt;p&gt;The asyncpg error message even says this. The hint attached to the exception tells you that PgBouncer in transaction or statement mode doesn't support prepared statements properly and suggests &lt;code&gt;statement_cache_size=0&lt;/code&gt;. I had been reading only the first line of the traceback for two days.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does statement_cache_size=0 fix it?
&lt;/h2&gt;

&lt;p&gt;Partly. Setting &lt;code&gt;statement_cache_size=0&lt;/code&gt; stops asyncpg from reusing cached named statements across transactions, which kills most of the "does not exist" errors. In my setup it took failures from 61 to 9 over a comparable 48-hour window. Better, not fixed.&lt;/p&gt;

&lt;p&gt;First attempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;connect_args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statement_cache_size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The remaining 9 were all "already exists." The reason: SQLAlchemy's asyncpg dialect keeps its &lt;em&gt;own&lt;/em&gt; prepared statement cache (&lt;code&gt;prepared_statement_cache_size&lt;/code&gt;), and it calls asyncpg's &lt;code&gt;prepare()&lt;/code&gt; explicitly. Explicitly prepared statements still get the sequential &lt;code&gt;__asyncpg_stmt_N__&lt;/code&gt; names. Two client connections counting from the same starting point, landing on the same server connection, still collide.&lt;/p&gt;

&lt;p&gt;So there are two caches and one naming scheme to deal with. I had fixed one cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the fix for asyncpg + PgBouncer prepared statement errors?
&lt;/h2&gt;

&lt;p&gt;Disable both caches, and make every prepared statement name globally unique so collisions are impossible even when PgBouncer shuffles connections. SQLAlchemy 2.0 has a parameter built for exactly this: &lt;code&gt;prepared_statement_name_func&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid4&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.ext.asyncio&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;create_async_engine&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.pool&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;NullPool&lt;/span&gt;

&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# points at PgBouncer, not Postgres
&lt;/span&gt;    &lt;span class="n"&gt;poolclass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;NullPool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# PgBouncer is the pool now
&lt;/span&gt;    &lt;span class="n"&gt;connect_args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statement_cache_size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;# asyncpg's cache
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prepared_statement_cache_size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;# SQLAlchemy's cache
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prepared_statement_name_func&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__asyncpg_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three changes, each for a separate reason:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;statement_cache_size=0&lt;/code&gt;&lt;/strong&gt; stops asyncpg from assuming a statement prepared earlier still exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;prepared_statement_cache_size=0&lt;/code&gt;&lt;/strong&gt; does the same for SQLAlchemy's layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;prepared_statement_name_func&lt;/code&gt;&lt;/strong&gt; replaces &lt;code&gt;__asyncpg_stmt_1a__&lt;/code&gt; with a UUID. Two workers can no longer pick the same name, so "already exists" becomes mathematically boring.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also switched to &lt;code&gt;NullPool&lt;/code&gt;. Running a SQLAlchemy pool on top of PgBouncer is double pooling: app-side connections sit idle holding PgBouncer client slots, and you get none of the multiplexing you added PgBouncer for. With &lt;code&gt;NullPool&lt;/code&gt;, each checkout opens a cheap client connection to PgBouncer and closes it after.&lt;/p&gt;

&lt;p&gt;Result: &lt;strong&gt;0 prepared statement errors across the next 7 days and 11,800 session-end writes&lt;/strong&gt;, including two evenings with more than 30 concurrent writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does disabling prepared statements cost?
&lt;/h2&gt;

&lt;p&gt;Every query now gets parsed and planned fresh instead of reusing a plan. For my workload, short inserts and indexed lookups, p50 query time went from 3.1 ms to 3.6 ms and p99 from 14 ms to 17 ms. The session-end burst is about 40 statements, so that's roughly 20 ms added to a write the user never waits on.&lt;/p&gt;

&lt;p&gt;If your workload is heavy, repeated, complex queries, the parse cost could matter more. Measure your own p99 before and after rather than trusting my numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is there a fix on the PgBouncer side?
&lt;/h2&gt;

&lt;p&gt;Yes. Newer PgBouncer versions (1.21 and later) can track protocol-level named prepared statements in transaction mode via &lt;code&gt;max_prepared_statements&lt;/code&gt;. PgBouncer rewrites statement names and re-prepares them on whichever server connection you land on. I had to set it explicitly; it did nothing until I did.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[pgbouncer]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;pool_mode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;transaction&lt;/span&gt;
&lt;span class="py"&gt;max_prepared_statements&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tested this on staging and it also got the errors to zero, with the plan-caching benefit back. I still ship the client-side config in production for one reason: my managed Postgres provider controls the PgBouncer version, and I don't want correctness depending on a setting I can't see. If you own your PgBouncer, the server-side option is the better long-term answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd check first next time
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read the whole exception, including the hint.&lt;/strong&gt; asyncpg told me the answer on day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reproduce with concurrency, not with retries.&lt;/strong&gt; A single client never triggers this. I wrote a 40-coroutine script that fires the session-end write in parallel against staging PgBouncer, and it reproduced the bug in under a minute. Before that I had spent an evening adding retry logic, which turned 3% failures into 3% duplicate partial writes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count your caches.&lt;/strong&gt; asyncpg has one, SQLAlchemy has one. Any ORM wrapping a driver may add another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never let session-level state cross PgBouncer transaction mode.&lt;/strong&gt; Prepared statements are the famous one. &lt;code&gt;SET&lt;/code&gt; without &lt;code&gt;LOCAL&lt;/code&gt;, advisory locks, &lt;code&gt;LISTEN&lt;/code&gt;, and temp tables break the same way.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So why does asyncpg throw "prepared statement already exists" behind PgBouncer?
&lt;/h2&gt;

&lt;p&gt;Because asyncpg + PgBouncer transaction mode puts named, per-connection prepared statements on top of a pooler that reassigns server connections between transactions. Your statement name points at a connection you no longer own, or collides with another client's identical sequential name on the connection you got. The fix that took my production errors from 61 to 0: set &lt;code&gt;statement_cache_size=0&lt;/code&gt; and &lt;code&gt;prepared_statement_cache_size=0&lt;/code&gt;, give prepared statements unique names with &lt;code&gt;prepared_statement_name_func&lt;/code&gt; (UUIDs work), and drop app-side pooling with &lt;code&gt;NullPool&lt;/code&gt;. If you control PgBouncer 1.21+, &lt;code&gt;max_prepared_statements&lt;/code&gt; solves it on the server side and keeps plan caching. Expect a small parse cost; mine was 0.5 ms at p50.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>performance</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>GitHub Actions GITHUB_TOKEN Doesn't Trigger Workflows: 11 Tags, 0 Runs</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Mon, 02 Nov 2026 12:18:17 +0000</pubDate>
      <link>https://dev.to/ji_ai/github-actions-githubtoken-doesnt-trigger-workflows-11-tags-0-runs-p20</link>
      <guid>https://dev.to/ji_ai/github-actions-githubtoken-doesnt-trigger-workflows-11-tags-0-runs-p20</guid>
      <description>&lt;p&gt;A user opened an issue on one of my side projects: "Is this package abandoned? The README says 1.4.6 but npm says 1.3.0."&lt;/p&gt;

&lt;p&gt;I checked GitHub. The tags were there: &lt;code&gt;v1.3.1&lt;/code&gt; through &lt;code&gt;v1.4.6&lt;/code&gt;, eleven of them across nineteen days, each cut by my release workflow. I checked npm. Still &lt;code&gt;1.3.0&lt;/code&gt;. Then I checked the Actions tab for the publish workflow that was supposed to fire on every tag.&lt;/p&gt;

&lt;p&gt;It had not run once. It hadn't failed or been skipped. There were simply no runs at all.&lt;/p&gt;

&lt;p&gt;The cause is a GitHub Actions rule that is documented, deliberate, and still catches people every week: &lt;strong&gt;GITHUB_TOKEN doesn't trigger workflows.&lt;/strong&gt; Anything your workflow does with the default token (push a commit, push a tag, open a PR, publish a release) will not start another workflow run.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Events created with the default &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; do not start new workflow runs. The only exceptions are &lt;code&gt;workflow_dispatch&lt;/code&gt; and &lt;code&gt;repository_dispatch&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;This covers pushes, tags, pull requests, releases and comments made by &lt;code&gt;github-actions[bot]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;GitHub does this on purpose, to stop workflows from triggering themselves in an infinite loop.&lt;/li&gt;
&lt;li&gt;Fixes: push with a GitHub App token or a PAT, call &lt;code&gt;gh workflow run&lt;/code&gt; explicitly, or turn the second workflow into a reusable workflow called by the first.&lt;/li&gt;
&lt;li&gt;If you switch tokens, pass the new token to &lt;code&gt;actions/checkout&lt;/code&gt;. Otherwise &lt;code&gt;git push&lt;/code&gt; keeps using &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; and nothing changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What did my broken release setup look like?
&lt;/h2&gt;

&lt;p&gt;Two workflows. The first one I ran by hand when I wanted a release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/release.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;release&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git config user.name "github-actions[bot]"&lt;/span&gt;
          &lt;span class="s"&gt;git config user.email "41898282+github-actions[bot]@users.noreply.github.com"&lt;/span&gt;
          &lt;span class="s"&gt;npm version patch&lt;/span&gt;
          &lt;span class="s"&gt;git push --follow-tags&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second one publishes whenever a version tag shows up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/publish.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;publish&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;npm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
          &lt;span class="na"&gt;registry-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://registry.npmjs.org&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci &amp;amp;&amp;amp; npm publish&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;NODE_AUTH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.NPM_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each half works on its own. When I tagged locally and pushed from my laptop, &lt;code&gt;publish&lt;/code&gt; ran perfectly. That's how the first few releases went out, and it's why I never suspected anything when I moved tagging into CI.&lt;/p&gt;

&lt;p&gt;Once the tag push came from inside a workflow, &lt;code&gt;publish&lt;/code&gt; went quiet. There was no error or warning anywhere, and the run list stayed empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why doesn't GITHUB_TOKEN trigger workflows?
&lt;/h2&gt;

&lt;p&gt;GITHUB_TOKEN doesn't trigger workflows because GitHub blocks recursion at the token level. Any event caused by the repository's automatic &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; is not allowed to create a new workflow run, except &lt;code&gt;workflow_dispatch&lt;/code&gt; and &lt;code&gt;repository_dispatch&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Imagine a workflow on &lt;code&gt;push&lt;/code&gt; that formats code and pushes the result. If that push triggered workflows, it would trigger itself, format again, push again, and burn your minutes forever. GitHub's answer is blunt: the built-in token can change your repo, but those changes are invisible to the trigger system.&lt;/p&gt;

&lt;p&gt;The rule is broader than "pushes," which is where people get bitten twice. All of these are silent when done with &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action done with GITHUB_TOKEN&lt;/th&gt;
&lt;th&gt;Workflow that won't run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;git push&lt;/code&gt; to a branch&lt;/td&gt;
&lt;td&gt;&lt;code&gt;on: push&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;git push&lt;/code&gt; a tag&lt;/td&gt;
&lt;td&gt;&lt;code&gt;on: push: tags&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open a PR (e.g. an auto-update bot)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;on: pull_request&lt;/code&gt; CI checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create a release via API or &lt;code&gt;gh release create&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;on: release&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comment on an issue&lt;/td&gt;
&lt;td&gt;&lt;code&gt;on: issue_comment&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The PR case is the nastiest one. A dependency-update or codegen workflow opens a PR with &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;, your required checks never start, and the PR sits forever at "Expected — Waiting for status to be reported." It looks like CI is broken, when really CI was never asked to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I tell if this is my problem?
&lt;/h2&gt;

&lt;p&gt;Look for the absence of a run, not a failed run. If this rule is your problem, the downstream workflow has no entry at all for that commit or tag.&lt;/p&gt;

&lt;p&gt;Three quick checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the tag or commit on GitHub. If it was pushed by &lt;code&gt;github-actions[bot]&lt;/code&gt;, suspect this rule first.&lt;/li&gt;
&lt;li&gt;Open the downstream workflow in the Actions tab. Zero runs for that ref means it was never triggered. A skipped run would show up as skipped, so a missing one points at the token.&lt;/li&gt;
&lt;li&gt;Push the same kind of tag from your laptop. If the workflow runs instantly, your trigger config is fine and the token is the problem.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last test took me 30 seconds and would have saved nineteen days.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I make a workflow trigger another workflow?
&lt;/h2&gt;

&lt;p&gt;You have four options. I'll go from the one I now use to the one I'd only use in a pinch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 1: Merge them with a reusable workflow
&lt;/h3&gt;

&lt;p&gt;Often the cleanest fix skips the second trigger entirely. Make &lt;code&gt;publish.yml&lt;/code&gt; callable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# publish.yml&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;workflow_call&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then call it as a job from &lt;code&gt;release.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# ...same as before...&lt;/span&gt;
  &lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tag&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/workflows/publish.yml&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inherit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing has to be "triggered," since publishing is just the next job. One catch: the called workflow checks out the ref of the caller, not the new tag, so check out the tag explicitly if your build reads the version from git.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 2: Dispatch it explicitly
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;workflow_dispatch&lt;/code&gt; is one of the two exceptions, so &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; is allowed to start it on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# ...after git push --follow-tags...&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gh workflow run publish.yml --ref "v$(node -p 'require(\"./package.json\").version')"&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.token }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;workflow_dispatch:&lt;/code&gt; to the &lt;code&gt;on:&lt;/code&gt; block of &lt;code&gt;publish.yml&lt;/code&gt;, or this call fails. And don't forget &lt;code&gt;actions: write&lt;/code&gt;, which the default permission set often doesn't include.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 3: Push with a GitHub App token
&lt;/h3&gt;

&lt;p&gt;If you really need the push itself to count as a normal event (other people's workflows, PR checks, release triggers), push with a token that isn't &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;. A GitHub App installation token is the best option. It isn't tied to a person, it expires on its own, and commits show up as &lt;code&gt;your-app[bot]&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/create-github-app-token@v1&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.RELEASE_APP_ID }}&lt;/span&gt;
      &lt;span class="na"&gt;private-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.RELEASE_APP_KEY }}&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.app.outputs.token }}&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;npm version patch&lt;/span&gt;
      &lt;span class="s"&gt;git push --follow-tags&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the setup that finally published &lt;code&gt;1.4.7&lt;/code&gt;. It also published nothing else, because I had already burned eleven version numbers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 4: Push with a fine-grained PAT
&lt;/h3&gt;

&lt;p&gt;Same idea, less setup: create a fine-grained personal access token with &lt;code&gt;Contents: read and write&lt;/code&gt; on that one repo, store it as a secret, pass it to checkout. It works. The downsides are that it acts as you, it expires on a date you'll forget, and it breaks when you leave the team.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did switching tokens still not work for me?
&lt;/h2&gt;

&lt;p&gt;The first time I tried Fix 3, I generated the App token, exported it as an env var, and ran &lt;code&gt;git push&lt;/code&gt;. Nothing triggered.&lt;/p&gt;

&lt;p&gt;The reason is &lt;code&gt;actions/checkout&lt;/code&gt;. By default it saves the token it used into the repo's local git config (&lt;code&gt;persist-credentials: true&lt;/code&gt;), and later git commands in the job authenticate with that saved credential. If checkout ran with the default token, your &lt;code&gt;git push&lt;/code&gt; goes out as &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; no matter what other secrets sit in the environment.&lt;/p&gt;

&lt;p&gt;The fix is the line in the example above: pass the new token to checkout via &lt;code&gt;token:&lt;/code&gt;. Checkout has to authenticate with the token you want the push to use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Won't a real token bring back the infinite loop?
&lt;/h2&gt;

&lt;p&gt;Yes. Once you push with an App token or PAT, you've opted out of GitHub's loop protection, so you need your own guard. If a &lt;code&gt;push&lt;/code&gt;-triggered workflow commits and pushes with a real token, it will trigger itself.&lt;/p&gt;

&lt;p&gt;Two guards that work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Put &lt;code&gt;[skip ci]&lt;/code&gt; in the bot's commit message. GitHub skips &lt;code&gt;push&lt;/code&gt; and &lt;code&gt;pull_request&lt;/code&gt; workflows for commits containing it.&lt;/li&gt;
&lt;li&gt;Add a job condition such as &lt;code&gt;if: github.actor != 'my-release-app[bot]'&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My release workflow runs on &lt;code&gt;workflow_dispatch&lt;/code&gt;, so it can't loop. But the moment you combine "on push" with "push with a PAT," write the guard first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;release.yml&lt;/code&gt; now uses a GitHub App token, passed to &lt;code&gt;actions/checkout&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;publish.yml&lt;/code&gt; also has &lt;code&gt;workflow_dispatch&lt;/code&gt;, so I can re-run a missed tag by hand with &lt;code&gt;gh workflow run publish.yml --ref v1.4.7&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A scheduled job compares the latest git tag with the npm &lt;code&gt;latest&lt;/code&gt; version and fails loudly if they differ. That check would have caught this on day one, not day nineteen.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Does GITHUB_TOKEN trigger workflows?
&lt;/h2&gt;

&lt;p&gt;No. In GitHub Actions, GITHUB_TOKEN doesn't trigger workflows: pushes, tags, pull requests, releases and comments created with the default &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; never start new workflow runs, except for &lt;code&gt;workflow_dispatch&lt;/code&gt; and &lt;code&gt;repository_dispatch&lt;/code&gt; events. GitHub does this to prevent recursive workflow loops. To chain workflows, call the second one as a reusable workflow, start it explicitly with &lt;code&gt;gh workflow run&lt;/code&gt;, or push using a GitHub App installation token or a fine-grained PAT that you also pass to &lt;code&gt;actions/checkout&lt;/code&gt;, and add a loop guard such as &lt;code&gt;[skip ci]&lt;/code&gt; when the triggering event is &lt;code&gt;push&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>tooling</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Python requests timeout=60 Let My LLM Stream Run for 38 Minutes</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Mon, 02 Nov 2026 00:16:44 +0000</pubDate>
      <link>https://dev.to/ji_ai/python-requests-timeout60-let-my-llm-stream-run-for-38-minutes-2mf5</link>
      <guid>https://dev.to/ji_ai/python-requests-timeout60-let-my-llm-stream-run-for-38-minutes-2mf5</guid>
      <description>&lt;p&gt;My nightly summarization job had &lt;code&gt;timeout=60&lt;/code&gt; on every request. One call ran for 38 minutes and 12 seconds anyway. Nothing crashed and nothing logged an error. The 6:00 a.m. report just didn't show up.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Python requests timeout&lt;/strong&gt; is not a deadline for the whole call. It caps how long you wait for the &lt;em&gt;next bytes&lt;/em&gt;. Point it at a streaming LLM endpoint that emits a token every 70 milliseconds and that timeout never fires.&lt;/p&gt;

&lt;p&gt;This post covers what I found over 14 nights and 33,600 calls, how I tracked it down, and the 20 lines that fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;requests.post(..., timeout=60)&lt;/code&gt; sets a &lt;strong&gt;connect timeout&lt;/strong&gt; and a &lt;strong&gt;read timeout&lt;/strong&gt;. The read timeout is the longest allowed gap &lt;em&gt;between bytes&lt;/em&gt;, not the total duration.&lt;/li&gt;
&lt;li&gt;A streaming LLM response sends bytes constantly, so it never trips the read timeout, even when the model is stuck in a repetition loop for half an hour.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;httpx&lt;/code&gt; works the same way: &lt;code&gt;Timeout(60)&lt;/code&gt; covers connect, read, write and pool separately. There is no total-time cap by default.&lt;/li&gt;
&lt;li&gt;Fix: set &lt;code&gt;max_tokens&lt;/code&gt;, keep a short read timeout, &lt;strong&gt;and&lt;/strong&gt; enforce a wall-clock deadline yourself (a check inside the stream loop, or &lt;code&gt;asyncio.timeout()&lt;/code&gt; around the whole call).&lt;/li&gt;
&lt;li&gt;After the fix: 0 missed reports in the next 14 nights. The longest call dropped from 38m12s to 1m51s.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Nothing exotic here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A 7B instruct model served by vLLM through its OpenAI-compatible endpoint&lt;/li&gt;
&lt;li&gt;A Python batch job with 6 worker threads&lt;/li&gt;
&lt;li&gt;About 2,400 support tickets per night, each summarized into a short JSON blob&lt;/li&gt;
&lt;li&gt;Streaming on, so I could log time-to-first-token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The call looked like this, and I'd guess half the LLM scripts on GitHub look the same:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iter_lines&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data: [DONE]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;
        &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I read &lt;code&gt;timeout=60&lt;/code&gt; as "this call dies after a minute." That was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the timeout parameter in Python requests actually do?
&lt;/h2&gt;

&lt;p&gt;The Python requests timeout sets two limits: how long to wait for the TCP connection, and how long to wait between bytes on the socket. Passing one number sets both to that value. Passing a tuple like &lt;code&gt;(5, 30)&lt;/code&gt; sets them separately. Neither one limits total request time.&lt;/p&gt;

&lt;p&gt;The requests docs say this outright. The read timeout is the number of seconds the client will wait for the server to send a byte. Every byte that arrives resets that clock.&lt;/p&gt;

&lt;p&gt;For a normal JSON API this almost never matters. The server is silent until it has an answer, so "time between bytes" and "total time" are basically the same number.&lt;/p&gt;

&lt;p&gt;Streaming changes that. A server streaming tokens is never silent for long. My model produced a chunk roughly every 70 ms under load, so the read timeout restarted about 14 times a second. A 60-second read timeout couldn't fire unless the model stopped completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why would an LLM stream run for 38 minutes?
&lt;/h2&gt;

&lt;p&gt;It ran that long because the model got stuck repeating itself, and nothing told it to stop. I hadn't set &lt;code&gt;max_tokens&lt;/code&gt;. vLLM's OpenAI-compatible server then allows generation up to whatever context window is left after the prompt. My context was 32k tokens, the prompts were about 1.5k, and that left roughly 30,000 tokens of room.&lt;/p&gt;

&lt;p&gt;At around 15 tokens per second per request under batch load, 30,000 tokens takes about 33 minutes. My worst call ran 38. The numbers line up.&lt;/p&gt;

&lt;p&gt;The outputs from those calls looked like what you'd expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"summary": "Customer reports login failure. Customer reports login failure.
Customer reports login failure. Customer reports login failure. ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That repeated a few thousand times. Some tickets (very long ones, pasted stack traces, logs full of near-identical lines) push small models into these loops. It's rare, but over 2,400 calls a night it happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  How rare, and how bad?
&lt;/h2&gt;

&lt;p&gt;Here's what 14 nights of logs showed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total calls&lt;/td&gt;
&lt;td&gt;33,600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p50 duration&lt;/td&gt;
&lt;td&gt;4.1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Calls over 5 minutes&lt;/td&gt;
&lt;td&gt;61 (0.18%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Longest call&lt;/td&gt;
&lt;td&gt;38m 12s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU time spent on those 61 calls&lt;/td&gt;
&lt;td&gt;~9.2 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nights the 6:00 a.m. report was late&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;0.18% sounds harmless until you remember there were only 6 workers. When 2 or 3 runaway calls landed on the same night, a third to half of my capacity spent half an hour generating "Customer reports login failure." The other workers also slowed down, because every runaway sequence took a slot in vLLM's batch and a chunk of KV cache.&lt;/p&gt;

&lt;p&gt;On the 3 late nights, 4 or more workers were stuck at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I found it
&lt;/h2&gt;

&lt;p&gt;The job wasn't failing, so I had no errors to read. What I had:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;py-spy dump&lt;/code&gt; on the stuck process.&lt;/strong&gt; All 6 threads were sitting in &lt;code&gt;iter_lines()&lt;/code&gt; → &lt;code&gt;urllib3&lt;/code&gt; → &lt;code&gt;socket.recv&lt;/code&gt;. So they weren't deadlocked. They were busy receiving data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;vLLM's &lt;code&gt;/metrics&lt;/code&gt; endpoint.&lt;/strong&gt; &lt;code&gt;vllm:num_requests_running&lt;/code&gt; sat at 6 with nothing waiting. The server thought it was doing useful work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging output length.&lt;/strong&gt; Normal summaries came out at 150 to 400 tokens. The 61 slow calls averaged about 21,000.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then I wrote a 15-line test server that sent one SSE line every 30 seconds and pointed &lt;code&gt;requests.post(..., timeout=60)&lt;/code&gt; at it. It sat there for an hour until I killed it. That settled it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you set a total timeout for a streaming request in Python?
&lt;/h2&gt;

&lt;p&gt;Set a total timeout by enforcing a wall-clock deadline in your own code. Neither &lt;code&gt;requests&lt;/code&gt; nor &lt;code&gt;httpx&lt;/code&gt; gives you one. Check elapsed time on every chunk, cap generation with &lt;code&gt;max_tokens&lt;/code&gt;, and close the connection when you give up so the server stops generating.&lt;/p&gt;

&lt;p&gt;Here's what I run now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stream_completion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deadline_s&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;read_timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;finish&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                       &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;read_timeout&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iter_lines&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;deadline_s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exceeded &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;deadline_s&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s wall clock&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data: [DONE]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;choice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:])[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;finish&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;finish_reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;finish&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;finish&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four things changed, and each one matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;max_tokens=1024&lt;/code&gt;.&lt;/strong&gt; My longest legitimate summary was about 600 tokens. This alone puts a ceiling on the runaway case at roughly 70 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;timeout=(5, 20)&lt;/code&gt;.&lt;/strong&gt; Short connect timeout, moderate read timeout. This still catches a server that goes completely silent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The deadline check inside the loop.&lt;/strong&gt; This is what actually caps total time. It only runs when a chunk arrives, so the true worst case is &lt;code&gt;deadline_s + read_timeout&lt;/code&gt;, here 110 seconds. Know that number before you promise anyone an SLA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;with&lt;/code&gt; block.&lt;/strong&gt; Leaving it closes the socket. vLLM notices the disconnect and aborts the request, so the GPU stops generating junk for nobody. My server logs showed the aborts.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Is there a hard cap that doesn't depend on chunks arriving?
&lt;/h2&gt;

&lt;p&gt;Yes. With async code, wrap the entire call in &lt;code&gt;asyncio.timeout()&lt;/code&gt; (Python 3.11+). It cancels the task at the deadline whether or not bytes are flowing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stream_completion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aiter_lines&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cancellation unwinds the &lt;code&gt;async with&lt;/code&gt; and closes the stream. This is my preferred version. I kept the sync one above because the batch job is threaded and I didn't want to rewrite it at 2 a.m.&lt;/p&gt;

&lt;p&gt;If you use an SDK built on httpx, like the OpenAI Python client, read what its &lt;code&gt;timeout&lt;/code&gt; argument means for streams in the version you have installed. Don't assume it's a total deadline. Wrap the call in your own deadline either way. It costs one line.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about the 58 calls that hit max_tokens?
&lt;/h2&gt;

&lt;p&gt;Capping generation doesn't fix the loop. It just makes the loop cheap. In the 14 nights after the fix, 58 calls ended with &lt;code&gt;finish_reason == "length"&lt;/code&gt;, which in this job always meant a repetition loop.&lt;/p&gt;

&lt;p&gt;I handle those explicitly now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never parse them as valid output. A truncated JSON blob full of repeated sentences is garbage.&lt;/li&gt;
&lt;li&gt;Retry once at a slightly higher temperature (0.3 instead of 0). &lt;strong&gt;49 of 58&lt;/strong&gt; came back clean.&lt;/li&gt;
&lt;li&gt;Send the other &lt;strong&gt;9&lt;/strong&gt; to a manual review queue with the ticket ID.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's 9 tickets a fortnight for a person to look at, instead of 9 GPU-hours of "Customer reports login failure."&lt;/p&gt;

&lt;h2&gt;
  
  
  Results after the fix
&lt;/h2&gt;

&lt;p&gt;Same job, same model, next 14 nights:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Longest call: &lt;strong&gt;1m 51s&lt;/strong&gt; (it hit &lt;code&gt;max_tokens&lt;/code&gt;, then went through the retry path)&lt;/li&gt;
&lt;li&gt;Calls over 5 minutes: &lt;strong&gt;0&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Late reports: &lt;strong&gt;0&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Total nightly runtime dropped by about 11%, mostly because runaway sequences stopped taking batch slots from healthy requests&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The checklist I now apply to every LLM HTTP call
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Always set &lt;code&gt;max_tokens&lt;/code&gt;. A server default of "whatever context is left" is not a limit.&lt;/li&gt;
&lt;li&gt;Use a tuple timeout: short connect, moderate read.&lt;/li&gt;
&lt;li&gt;Add a wall-clock deadline that you enforce yourself.&lt;/li&gt;
&lt;li&gt;Close the stream when you give up so the server stops generating.&lt;/li&gt;
&lt;li&gt;Treat &lt;code&gt;finish_reason == "length"&lt;/code&gt; as a failure until proven otherwise.&lt;/li&gt;
&lt;li&gt;Log output token counts. That's how the 21,000-token outliers showed up.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  So why didn't timeout=60 stop my request?
&lt;/h2&gt;

&lt;p&gt;Because the Python requests timeout only limits connecting and the gap between received bytes. It never limits total request duration. A streaming LLM response sends a byte every few milliseconds, so the read timeout keeps resetting and a call can run as long as the model keeps generating: 38 minutes in my case, with no &lt;code&gt;max_tokens&lt;/code&gt; set. To actually bound a streaming call, set &lt;code&gt;max_tokens&lt;/code&gt;, keep a short read timeout, enforce a wall-clock deadline in your own loop or with &lt;code&gt;asyncio.timeout()&lt;/code&gt;, and close the connection when the deadline hits so the server stops generating.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>llm</category>
      <category>performance</category>
      <category>ai</category>
    </item>
    <item>
      <title>My Claude Code Sign-in Fix Needed Two Fixes of Its Own</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sun, 01 Nov 2026 18:29:43 +0000</pubDate>
      <link>https://dev.to/ji_ai/my-claude-code-sign-in-fix-needed-two-fixes-of-its-own-14m9</link>
      <guid>https://dev.to/ji_ai/my-claude-code-sign-in-fix-needed-two-fixes-of-its-own-14m9</guid>
      <description>&lt;p&gt;On September 29 I removed a sign-in refusal from my app. The next day a review pass whose only job was to break that change found that it left some sign-ins reported as ready that then failed, and my repair of that opened a second hole. Both were caught before the first public release on October 1.&lt;/p&gt;

&lt;p&gt;Refmade is a desktop app that wraps the Claude Code and Codex command-line tools and draws what they are doing as an office floor. It is an independent app, not affiliated with Anthropic or OpenAI. The UI is Korean right now; that changes later in this series. Claude Code and Codex agents wrote most of the code, working from specs I gave them. I planned the work, made the calls and reviewed the results. This post is about one call that turned out to be half right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: the app refused a sign-in the CLI accepts
&lt;/h2&gt;

&lt;p&gt;The app has a "team" mode, where several agents plan, write and check a task. Until September 29 it only worked when Claude Code was signed in with a Claude account (the plan login). If you had an API key instead, the app answered with a reason code, &lt;code&gt;AUTH_MODE_UNSUPPORTED&lt;/code&gt;, and stopped.&lt;/p&gt;

&lt;p&gt;I read Anthropic's documentation around that refusal. My conclusion was that an app wrapping the CLI should not refuse a sign-in method the CLI itself supports. I'm a developer, not a lawyer, and this is not legal advice. It is simply how I decided to design it, so I asked for the refusal to be removed.&lt;/p&gt;

&lt;p&gt;The app also tells people what it does with their data. Here is the first screen as it looked in a capture from the end of September:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffjfhmjzz0t0fj11rgdx5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffjfhmjzz0t0fj11rgdx5.png" alt="Refmade preview-mode first screen with two cards, Claude Code ready and Codex needing login, and four plain notes below about data, sign-in, usage and wrong answers" width="800" height="599"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Preview-mode first screen, late September (Korean UI, demo state). Title: "First, let's get an AI ready." Cards: Claude Code "ready", Codex "login needed". Notes below, translated: the app itself sends nothing to the internet; when you hand it a task, the Claude Code or Codex on your computer sends your request and needed file contents to Anthropic, OpenAI or wherever you connected, and checking that the AI is ready can also reach Anthropic; sign-in is managed by each AI tool and the app only checks whether and how you are signed in; tasks use up your plan's usage; AI answers can be wrong. A fifth note, cropped here, says the app is not made or endorsed by Anthropic or OpenAI.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One correction to that first note. It was true when I took the capture. Since version 0.3.0 the app also asks for a version number once a day, and since 0.3.3 it fetches banners for my other services in the office view. Neither request carries task content, but "sends nothing" is no longer an accurate sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review round one: the status check and the run disagree
&lt;/h2&gt;

&lt;p&gt;Removing the refusal was a small change. I asked for an adversarial review (September 30), a few agents whose only task is to find how the change fails, and it found this.&lt;/p&gt;

&lt;p&gt;Team mode starts Claude Code with &lt;code&gt;--restricted&lt;/code&gt;, which keeps settings files out of the run. That includes your own user settings file. But before a run, the app asks &lt;code&gt;claude auth status --json&lt;/code&gt; whether you are ready, and that command does read the user settings file. Some sign-ins live only there: an &lt;code&gt;apiKeyHelper&lt;/code&gt;, or the Bedrock and Google Cloud login wizards.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Reads user settings file?&lt;/th&gt;
&lt;th&gt;What it sees&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Readiness check (&lt;code&gt;auth status&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;"signed in, ready"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team run (&lt;code&gt;--restricted&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no sign-in at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Result&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;run fails, or quietly uses another stored login&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I reproduced it with a fake API key and a local test server standing in for the API. The run either failed after the screen said "ready" or went out under a different Claude account that happened to be stored on the machine. For those people the screen now showed a green light, followed by a failure or a silent account switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and the second problem inside it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--settings&lt;/code&gt; still applies under &lt;code&gt;--restricted&lt;/code&gt;, so the app can hand the file back by path. I never read its contents. The app checks that it exists, is at most 2 MB and is readable, and Claude Code reads the rest. This is the condition from &lt;code&gt;providers/claude.mjs&lt;/code&gt; (one branch for third-party providers trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;signInFromSettings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;has&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;envValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api_key_helper&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keySource&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;apiKeyHelper&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keySource&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;oauth_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ANTHROPIC_AUTH_TOKEN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first version passed the file whenever a sign-in existed. A second review pass, one agent this time, found that the same file can contain &lt;code&gt;permissions.additionalDirectories&lt;/code&gt;. Handing it back would let a run reach folders outside the one you picked. The restriction I was relying on was gone. So the file is passed only when the sign-in actually comes from it.&lt;/p&gt;

&lt;p&gt;That condition has a trap. &lt;code&gt;auth status&lt;/code&gt; reports a key from the environment and a key from the settings file with the same label, &lt;code&gt;api_key&lt;/code&gt; and &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;. The only way I found to tell them apart was to check whether the run's own environment holds the key, which is what &lt;code&gt;has(...)&lt;/code&gt; does above. A related lesson from the Codex side: its &lt;code&gt;-c&lt;/code&gt; override values are type-checked, so a value that looks right can still be rejected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other things the same review turned up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Team runs with Codex forced the provider to OpenAI. People using Azure or another provider had their tasks sent to OpenAI. I removed the forcing.&lt;/li&gt;
&lt;li&gt;Claude Code and Codex could each receive the other's API key through the environment. Each tool now gets only its own family of variables (&lt;code&gt;ENV_FAMILIES&lt;/code&gt; in the code).&lt;/li&gt;
&lt;li&gt;The label "Claude account" appeared for any login. It now appears only for a plan login.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What passed, and what that proves
&lt;/h2&gt;

&lt;p&gt;After the fixes: app tests 405, CLI tests 323, engine tests 401 plus one. I also ran mutation checks, which deliberately break the code and confirm a test fails. Real team runs through the actual CLIs finished in 6.1 seconds for Claude and 14.3 seconds for Codex.&lt;/p&gt;

&lt;p&gt;Those numbers say the code does what my specs say. They say nothing about whether real users find sign-in easy. I have not run a usability test with real users.&lt;/p&gt;

&lt;h2&gt;
  
  
  A correction that belongs here
&lt;/h2&gt;

&lt;p&gt;On September 29 I reported that the app's macOS signature check had zero fatal findings. That was wrong. When I ran &lt;code&gt;syspolicy_check&lt;/code&gt; again, it listed three: two about the signature and one about missing notarization. Version 0.2.3 (September 30, 08:07) went out ad-hoc signed with 1,042 files sealed, and the count dropped from three to one. The remaining one is notarization, which I chose not to do. The next post covers that choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A status check and a run must read the same inputs. If they differ, "ready" is a promise the run can't keep.&lt;/li&gt;
&lt;li&gt;Review the fix, not only the original change. Both regressions here came from the repairs, not from the code that was already there.&lt;/li&gt;
&lt;li&gt;Reproduce with fake credentials and a local server before you trust a login fix.&lt;/li&gt;
&lt;li&gt;Say what a test count measures. 405 passing tests is a statement about the specs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app link comes in the last post of the series.&lt;/p&gt;

&lt;p&gt;If you wrap a CLI that has several ways to sign in, how do you keep your readiness check and your actual run reading the same configuration?&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>electron</category>
      <category>security</category>
      <category>ai</category>
    </item>
    <item>
      <title>LLM-as-Judge Position Bias: Swapping A/B Flipped 87 of 300 Verdicts</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sun, 01 Nov 2026 12:15:13 +0000</pubDate>
      <link>https://dev.to/ji_ai/llm-as-judge-position-bias-swapping-ab-flipped-87-of-300-verdicts-42hf</link>
      <guid>https://dev.to/ji_ai/llm-as-judge-position-bias-swapping-ab-flipped-87-of-300-verdicts-42hf</guid>
      <description>&lt;p&gt;In a head-to-head eval, my new system prompt beat the old one in 66% of comparisons. I already had the "we're shipping v2" message typed.&lt;/p&gt;

&lt;p&gt;Then I swapped which answer went first and reran the exact same 300 pairs. The new prompt won 43%.&lt;/p&gt;

&lt;p&gt;The judge, the answers, the rubric and temperature 0 were all the same. The only change was which answer sat in the slot labeled "A". That is &lt;strong&gt;LLM-as-judge position bias&lt;/strong&gt;, and if your eval script calls &lt;code&gt;judge(new, old)&lt;/code&gt; in a fixed order, your headline number is probably measuring it too.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LLM-as-judge position bias&lt;/strong&gt; means a pairwise judge prefers whichever answer sits in a particular slot (usually the first) regardless of quality.&lt;/li&gt;
&lt;li&gt;In my run, swapping A and B flipped &lt;strong&gt;87 of 300 verdicts (29%)&lt;/strong&gt;, and 78 of those flips went to whichever answer was in slot A.&lt;/li&gt;
&lt;li&gt;The bias shows up in close calls. When two answers are near-equal, slot position acts as the tiebreaker.&lt;/li&gt;
&lt;li&gt;Fix: judge every pair in &lt;strong&gt;both orders&lt;/strong&gt;. Count a win only when both runs agree, and treat disagreement as a tie.&lt;/li&gt;
&lt;li&gt;Before trusting a judge, run the &lt;strong&gt;mirror test&lt;/strong&gt;: put the identical answer in both slots. A fair judge has no reason to prefer either one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is LLM-as-judge position bias?
&lt;/h2&gt;

&lt;p&gt;LLM-as-judge position bias is a judge model's tendency to pick an answer because of where it appears in the prompt, not because of what it says. It's one of the first biases documented for LLM judges (the MT-Bench / Chatbot Arena paper from Zheng et al. called it out back in 2023), and it's still easy to walk straight into.&lt;/p&gt;

&lt;p&gt;It hits pairwise evals hardest, the "here are Answer A and Answer B, which is better?" setup. That's the setup most of us use to compare prompts, models or RAG configs, because it's easier than defining an absolute 1-10 scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I caught it
&lt;/h2&gt;

&lt;p&gt;My eval script looked like every eval script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ask_judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_answer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;old_answer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;wins&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The new answer always went in slot A. I didn't pick that order for any reason. It was just the order of the function arguments.&lt;/p&gt;

&lt;p&gt;A coworker asked "did you swap them?" and I had no good answer. So I ran it again with the old answer in slot A. Here is what the 300 pairs did across both runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outcome across both orders&lt;/th&gt;
&lt;th&gt;Pairs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New prompt won both times&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Old prompt won both times&lt;/td&gt;
&lt;td&gt;93&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slot A won both times (verdict flipped)&lt;/td&gt;
&lt;td&gt;78&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slot B won both times (verdict flipped)&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Count the slot picks: slot A won 369 of 600 judgments, or 61.5%. A judge with no position preference would land somewhere near 50%.&lt;/p&gt;

&lt;p&gt;The 66% headline was really 120 consistent wins plus 78 pairs where the new prompt just happened to sit in the favored seat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does an LLM judge prefer the first answer?
&lt;/h2&gt;

&lt;p&gt;An LLM judge prefers the first answer mostly because of how it reads: one token at a time, left to right. Answer A gets evaluated with nothing to compare it to. By the time the model reaches Answer B, it has already formed a picture of what "good" looks like for this question, and that picture came from A.&lt;/p&gt;

&lt;p&gt;A few things stack on top of that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Close calls get decided by the seat.&lt;/strong&gt; I read the 87 flipped pairs myself. In most of them I couldn't pick a winner either: both answers were correct, worded differently, and about the same length. With no real quality signal to go on, the position prior decides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temperature 0 doesn't help.&lt;/strong&gt; It makes the bias reproducible, which is worse in a way, because rerunning the same order gives you the same wrong answer and feels like confirmation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The direction isn't universal.&lt;/strong&gt; Some judge and prompt combinations favor slot B, especially with long answers where the most recent text is freshest. My judge favored A. Yours might favor B. Measure it before you assume a direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Format priors.&lt;/strong&gt; "A" is the first option in countless multiple-choice examples the model has seen. Label tokens carry their own baggage before the model reads a word of the answers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this means the judge is useless. On the 213 pairs where it agreed with itself, its picks matched mine on almost every one I spot-checked. The judge is fine at clear differences. On near-ties it isn't measuring quality at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you test an LLM judge for position bias?
&lt;/h2&gt;

&lt;p&gt;Run the mirror test: build pairs where Answer A and Answer B are the exact same text, then ask the judge to pick. A fair judge has no basis to prefer either, so it should say TIE or split close to 50/50.&lt;/p&gt;

&lt;p&gt;I ran 50 mirror pairs. My judge picked A 34 times, B 9 times and TIE 7 times. It was confidently picking a winner between two copies of the same text, and it usually picked the first one.&lt;/p&gt;

&lt;p&gt;This takes ten minutes and tells you more about your judge than a week of reading verdicts. I now run it every time I change the judge model or the judge prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you fix position bias in pairwise LLM evals?
&lt;/h2&gt;

&lt;p&gt;Judge every pair twice, once in each order, and count a win only when both runs agree. If the judge contradicts itself, call it a tie. That's the whole fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;JUDGE_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;You are comparing two answers to the same question.

Question:
{question}

Answer A:
{a}

Answer B:
{b}

Briefly list the strengths and weaknesses of each answer.
Then end with exactly one line: VERDICT: A, VERDICT: B, or VERDICT: TIE&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ask_judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JUDGE_PROMPT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parse_verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# "A" | "B" | "TIE"
&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;judge_pair&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;run1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ask_judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# new in slot A
&lt;/span&gt;    &lt;span class="n"&gt;run2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ask_judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# new in slot B
&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;run1&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;run2&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;run1&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;run2&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;old&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="c1"&gt;# Self-contradiction, or a TIE in either run: no signal.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tie&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then report three numbers instead of one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;judge_pair&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;new_wins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;old_wins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;old&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ties&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tie&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;new_wins&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  old &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;old_wins&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  tie &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ties&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new win rate on decisive pairs: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;new_wins&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_wins&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;old_wins&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tie rate is the important number here. It tells you how much of your eval set the judge can't actually separate. If it's 30%, a 5-point swing in your headline doesn't mean much.&lt;/p&gt;

&lt;p&gt;Some other things that helped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Give the judge a TIE option.&lt;/strong&gt; Without it, you're forcing a coin flip on every near-equal pair, and the position prior is what decides the flip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask for reasoning before the verdict.&lt;/strong&gt; Put the verdict line last so the model writes its comparison before it commits to a letter. It doesn't remove the bias, but it cut my flip rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use logprobs if your API exposes them.&lt;/strong&gt; Take P("A") in one order and P("B") in the swapped order, then average. You get a soft score that's symmetric by construction.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Isn't randomizing the A/B order enough?
&lt;/h2&gt;

&lt;p&gt;Randomizing the order per pair is better than a fixed order, but it doesn't fix the problem. Random assignment spreads the position bias evenly across both systems, so your aggregate win rate stops being skewed in one direction. That's real progress.&lt;/p&gt;

&lt;p&gt;What it hides is the noise. Those 87 coin-flip pairs are still in your data, now split randomly between "new won" and "old won." Your headline drifts toward 50% and looks like an honest close race, when actually a third of your eval set says nothing at all. Rerun it with a different random seed and the number moves.&lt;/p&gt;

&lt;p&gt;Running both orders costs twice the judge calls. On 300 pairs, that's 300 extra API calls. Shipping a prompt that was never better costs more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which other LLM judge biases show up alongside position bias?
&lt;/h2&gt;

&lt;p&gt;Position bias rarely travels alone. Watch for these two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verbosity bias.&lt;/strong&gt; Judges tend to favor the longer answer, even when the extra length is padding. If your new prompt makes answers 40% longer, some of its "wins" are word count. Check the win rate split by length difference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-preference.&lt;/strong&gt; A judge can favor outputs that sound like its own writing. If you're comparing Model X against Model Y and Model X is also the judge, be suspicious of a big X win.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Swapping the order fixes neither of these. They need their own checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  So was my new prompt actually better?
&lt;/h2&gt;

&lt;p&gt;Yes, a little. On the 213 pairs where the judge agreed with itself, the new prompt won 120 to 93, which is 56%. That's a real but modest improvement. It isn't the 66% blowout I nearly announced. I shipped it with a much quieter message and a note that 29% of the eval set was a toss-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does LLM-as-judge position bias distort pairwise evals?
&lt;/h2&gt;

&lt;p&gt;LLM-as-judge position bias makes a pairwise judge favor whichever answer occupies a particular slot, usually the first. It mostly decides near-tie comparisons, which can quietly inflate or deflate a win rate by double digits. In my eval, swapping A and B flipped 87 of 300 verdicts and turned a 66% win into 43%. The fix is to judge every pair in both orders, count only verdicts that agree, treat contradictions as ties, and report the tie rate next to the win rate. Run the mirror test, with the identical answer in both slots, before you trust any judge.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>machinelearning</category>
      <category>ai</category>
      <category>python</category>
    </item>
    <item>
      <title>git push --force-with-lease Is Unsafe If Your Editor Auto-Fetches</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sun, 01 Nov 2026 00:13:30 +0000</pubDate>
      <link>https://dev.to/ji_ai/git-push-force-with-lease-is-unsafe-if-your-editor-auto-fetches-37ja</link>
      <guid>https://dev.to/ji_ai/git-push-force-with-lease-is-unsafe-if-your-editor-auto-fetches-37ja</guid>
      <description>&lt;p&gt;I have typed &lt;code&gt;git push --force-with-lease&lt;/code&gt; for years with the smug calm of someone wearing a seatbelt. Then one afternoon it overwrote two of my teammate's commits on our shared feature branch, and git didn't print a single warning.&lt;/p&gt;

&lt;p&gt;No typo, no stale alias, no plain &lt;code&gt;--force&lt;/code&gt;. The lease checked out fine. The problem was my editor, quietly running &lt;code&gt;git fetch&lt;/code&gt; in the background every few minutes, which turned my seatbelt into a decorative strap.&lt;/p&gt;

&lt;p&gt;This post explains exactly why &lt;code&gt;git push --force-with-lease&lt;/code&gt; is unsafe when something auto-fetches, how to reproduce it in two minutes, and the flag that actually closes the hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;git push --force-with-lease&lt;/code&gt; with no argument compares the remote branch to &lt;strong&gt;your remote-tracking ref&lt;/strong&gt; (&lt;code&gt;origin/feature&lt;/code&gt;), not to the commit you actually rebased on.&lt;/li&gt;
&lt;li&gt;Any background &lt;code&gt;git fetch&lt;/code&gt; (VS Code's &lt;code&gt;git.autofetch&lt;/code&gt;, GitHub Desktop, a cron job, a prompt script) updates &lt;code&gt;origin/feature&lt;/code&gt; to include your teammate's new commits. The lease now matches, and your push deletes their work.&lt;/li&gt;
&lt;li&gt;The fix is &lt;code&gt;--force-if-includes&lt;/code&gt; (Git 2.30+), which refuses the push if the remote tip was never part of your local branch's history.&lt;/li&gt;
&lt;li&gt;Set it once: &lt;code&gt;git config --global push.useForceIfIncludes true&lt;/code&gt;. It only kicks in alongside &lt;code&gt;--force-with-lease&lt;/code&gt;, so plain &lt;code&gt;--force&lt;/code&gt; stays dangerous.&lt;/li&gt;
&lt;li&gt;Lost commits are usually recoverable from &lt;code&gt;git reflog show origin/&amp;lt;branch&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does git push --force-with-lease actually check?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git push --force-with-lease&lt;/code&gt; checks one thing: that the branch on the remote still points to the same commit your local remote-tracking ref points to. If they match, the force push goes through. If they differ, git rejects it with "stale info".&lt;/p&gt;

&lt;p&gt;That's it. It doesn't know which commit you started your rebase from. It doesn't know what you've looked at. It reads &lt;code&gt;refs/remotes/origin/feature&lt;/code&gt; and assumes that's the last state you saw.&lt;/p&gt;

&lt;p&gt;That assumption holds when you are the only thing that runs &lt;code&gt;git fetch&lt;/code&gt;. It quietly breaks the moment anything else does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a background fetch break the lease?
&lt;/h2&gt;

&lt;p&gt;A background fetch breaks the lease because it updates the exact ref the lease compares against, without touching your branch. Your view of the world goes stale, but git's record of "what you last saw" gets refreshed behind your back.&lt;/p&gt;

&lt;p&gt;Here's the timeline from my afternoon:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;14:02. I start an interactive rebase on &lt;code&gt;feature/billing&lt;/code&gt; to squash some fixup commits. My local branch and &lt;code&gt;origin/feature/billing&lt;/code&gt; both point at commit &lt;code&gt;A&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;14:09. My teammate pushes two commits, &lt;code&gt;B&lt;/code&gt; and &lt;code&gt;C&lt;/code&gt;, to the same branch.&lt;/li&gt;
&lt;li&gt;14:11. VS Code's autofetch runs. &lt;code&gt;origin/feature/billing&lt;/code&gt; now points at &lt;code&gt;C&lt;/code&gt;. My local branch is still my rewritten history on top of the old base.&lt;/li&gt;
&lt;li&gt;14:15. I finish the rebase and run &lt;code&gt;git push --force-with-lease&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Git compares: remote says &lt;code&gt;C&lt;/code&gt;, my tracking ref says &lt;code&gt;C&lt;/code&gt;. Lease matches. Push accepted. &lt;code&gt;B&lt;/code&gt; and &lt;code&gt;C&lt;/code&gt; are gone from the remote.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The lease worked perfectly. It protected me against a state I had technically "seen". I hadn't seen anything. My editor had.&lt;/p&gt;

&lt;p&gt;The git documentation actually warns about this in the &lt;code&gt;--force-with-lease&lt;/code&gt; section: if something updates your remote-tracking refs in the background, the default lease form gives you no protection. Almost nobody reads that paragraph, because the flag's name sounds like it already handled it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I reproduce the force-with-lease overwrite?
&lt;/h2&gt;

&lt;p&gt;You can reproduce it locally with a bare repo and two clones, no network needed. Paste this into a scratch directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git init &lt;span class="nt"&gt;--bare&lt;/span&gt; origin.git
git clone origin.git me
git clone origin.git them

&lt;span class="c"&gt;# me: create a branch with one commit&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;me
&lt;span class="nb"&gt;echo &lt;/span&gt;a &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; f &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git add f &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"base"&lt;/span&gt;
git push origin HEAD:main
git branch &lt;span class="nt"&gt;-u&lt;/span&gt; origin/main

&lt;span class="c"&gt;# them: pull it, add a fix, push&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ../them
git pull origin main
&lt;span class="nb"&gt;echo &lt;/span&gt;fix &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; g &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git add g &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"teammate fix"&lt;/span&gt;
git push origin HEAD:main

&lt;span class="c"&gt;# me: rewrite my history, then a "background" fetch happens&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ../me
git commit &lt;span class="nt"&gt;--amend&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"base (amended)"&lt;/span&gt;
git fetch            &lt;span class="c"&gt;# pretend this was your editor&lt;/span&gt;

git push &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt;
git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; origin/main   &lt;span class="c"&gt;# "teammate fix" is gone&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The push succeeds. Now repeat the last section with the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git push &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt; &lt;span class="nt"&gt;--force-if-includes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This time git rejects the push, because the remote tip (&lt;code&gt;teammate fix&lt;/code&gt;) never appeared in the history of your local branch. The rejection reason says the remote ref was updated since checkout, which is precisely the truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does --force-if-includes do?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--force-if-includes&lt;/code&gt; makes git verify that the commit your remote-tracking ref points to is something your local branch has actually included at some point. It checks the reflog of your local branch: if the remote tip isn't reachable from any entry, git assumes you never integrated it and refuses the force push.&lt;/p&gt;

&lt;p&gt;That's the missing check. The lease asks "did the remote change since my last fetch?" The include check asks "did I ever incorporate what's on the remote?" A background fetch can fake the first answer. It can't fake the second, because it never touches your local branch.&lt;/p&gt;

&lt;p&gt;A few rules worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It only works together with &lt;code&gt;--force-with-lease&lt;/code&gt;. With plain &lt;code&gt;--force&lt;/code&gt; it does nothing.&lt;/li&gt;
&lt;li&gt;It's ignored if you pass an explicit lease value, like &lt;code&gt;--force-with-lease=feature:abc123&lt;/code&gt;, since you've already told git exactly what to expect.&lt;/li&gt;
&lt;li&gt;It requires Git 2.30 or newer. Run &lt;code&gt;git --version&lt;/code&gt; before you rely on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do I make --force-if-includes the default?
&lt;/h2&gt;

&lt;p&gt;Set one config option and keep typing what you already type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; push.useForceIfIncludes &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every &lt;code&gt;git push --force-with-lease&lt;/code&gt; also runs the include check. I also added an alias so my muscle memory never reaches for plain &lt;code&gt;--force&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; alias.pushf &lt;span class="s2"&gt;"push --force-with-lease --force-if-includes"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The explicit flag in the alias is redundant with the config, but it means the alias still works on a machine where I forgot to set the config. Cheap insurance.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does --force-if-includes still let you overwrite work?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--force-if-includes&lt;/code&gt; still lets you overwrite work if your teammate's commits were in your local branch at any point, even briefly. Because the check looks at the reflog, "I pulled it once" counts as "I included it".&lt;/p&gt;

&lt;p&gt;The classic case: you &lt;code&gt;git pull --rebase&lt;/code&gt;, see your teammate's commits, then start an interactive rebase and drop them (on purpose or by fat-fingering &lt;code&gt;d&lt;/code&gt; instead of &lt;code&gt;p&lt;/code&gt;). The tip was in your reflog, so the check passes, and the push removes their work. Git is trusting that you made a decision. It can't tell the difference between a decision and an accident.&lt;/p&gt;

&lt;p&gt;If you want the strongest guarantee, use an explicit lease captured before you start rewriting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;BASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse origin/feature/billing&lt;span class="si"&gt;)&lt;/span&gt;
git rebase &lt;span class="nt"&gt;-i&lt;/span&gt; main
git push &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;feature/billing:&lt;span class="nv"&gt;$BASE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the push only succeeds if the remote is exactly the commit you started from. Background fetches can't touch a shell variable. This is what I use for long-lived shared branches. For my own short branches, the config option is enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I recover commits lost to a force push?
&lt;/h2&gt;

&lt;p&gt;You can usually recover them from the reflog of your remote-tracking ref, because the background fetch that caused the problem also recorded your teammate's commits locally. Run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git reflog show origin/feature/billing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see an entry from the fetch pointing at the old remote tip (in my case, &lt;code&gt;C&lt;/code&gt;). Cherry-pick the lost commits back onto your branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git cherry-pick &amp;lt;sha-of-B&amp;gt; &amp;lt;sha-of-C&amp;gt;
git push &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt; &lt;span class="nt"&gt;--force-if-includes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your reflog doesn't have them, your teammate's local clone almost certainly does. That's how we recovered: a slightly awkward "hey, can you push again?" message and five minutes of cleanup. Lucky. On a branch with three people and a CI bot, it would have been a much longer afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I stop auto-fetching instead?
&lt;/h2&gt;

&lt;p&gt;No. Auto-fetch is genuinely useful: it keeps your branch status accurate and shows incoming changes before you start a rebase. Turning it off trades a precise fix for a vague one, and you'd still get bitten by the next tool that fetches on your behalf.&lt;/p&gt;

&lt;p&gt;The better move is to stop trusting the default lease form. Fetching should never be what decides whether a destructive push is safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  So is git push --force-with-lease safe?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git push --force-with-lease&lt;/code&gt; is only safe if nothing else runs &lt;code&gt;git fetch&lt;/code&gt; in your repo, because by default it compares the remote against your remote-tracking ref, and a background fetch silently updates that ref to include commits you never saw. With VS Code autofetch, GitHub Desktop, or any scheduled fetch, the lease will happily approve a push that deletes your teammate's work. Add &lt;code&gt;--force-if-includes&lt;/code&gt; (or set &lt;code&gt;git config --global push.useForceIfIncludes true&lt;/code&gt; on Git 2.30+) so git also checks that the remote tip was ever part of your local branch, and use an explicit &lt;code&gt;--force-with-lease=branch:sha&lt;/code&gt; when you need a hard guarantee on shared branches.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tooling</category>
      <category>cli</category>
      <category>devops</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
