<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://joeywang.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://joeywang.github.io/" rel="alternate" type="text/html" /><updated>2026-08-13T11:03:01+00:00</updated><id>https://joeywang.github.io/feed.xml</id><title type="html">Joey</title><subtitle>The world of learning is constantly evolving and working on e-learning allows me to be a part of that challenge. I love being in a supportive team, with people from different backgrounds and specialties, and exploring the opportunities that we make possible together.</subtitle><entry><title type="html">When a Rails or LMS Platform Becomes Hard to Change</title><link href="https://joeywang.github.io/posts/rails-lms-reliability-modernization/" rel="alternate" type="text/html" title="When a Rails or LMS Platform Becomes Hard to Change" /><published>2026-08-13T05:00:00+00:00</published><updated>2026-08-13T05:00:00+00:00</updated><id>https://joeywang.github.io/posts/rails-lms-reliability-modernization</id><content type="html" xml:base="https://joeywang.github.io/posts/rails-lms-reliability-modernization/"><![CDATA[<p>A platform rarely becomes difficult to change in one dramatic event. More often, the friction arrives gradually:</p>

<ul>
  <li>A small feature needs several weeks of investigation.</li>
  <li>A report or export blocks a web request.</li>
  <li>Background jobs fail without enough evidence to explain why.</li>
  <li>A database query is fast in development but painful in production.</li>
  <li>Nobody is confident which part of the system can be changed safely.</li>
  <li>A proposed rewrite sounds attractive because the current system is hard to understand.</li>
</ul>

<p>For a small education or SaaS team, these symptoms create a difficult choice. Keep adding features and accept the growing cost, or pause delivery and attempt a large modernization project with uncertain scope.</p>

<p>In many cases, the best first step is neither. It is a bounded investigation that separates the real constraints from the stories the team has started telling itself about the system.</p>

<h2 id="start-with-friction-not-technology">Start with friction, not technology</h2>

<p>“Legacy Rails application” is not yet a diagnosis. It describes an age or technology choice, but not the business problem.</p>

<p>A useful investigation starts with questions such as:</p>

<ul>
  <li>Which user or operational workflow is being delayed?</li>
  <li>How often does the problem happen?</li>
  <li>What does the delay cost in staff time, customer experience, or missed delivery?</li>
  <li>Which part of the workflow is measurable today?</li>
  <li>What has already been tried?</li>
  <li>Is the problem in application code, data shape, infrastructure, process, or all four?</li>
</ul>

<p>This changes the conversation from “Should we rewrite?” to “What evidence would justify the next change?”</p>

<h2 id="five-areas-worth-checking">Five areas worth checking</h2>

<h3 id="1-the-delivery-path">1. The delivery path</h3>

<p>Look at how a feature moves from idea to production.</p>

<p>Warning signs include:</p>

<ul>
  <li>Nobody can explain the deployment path end to end.</li>
  <li>Tests are slow, unreliable, or routinely skipped.</li>
  <li>A change requires several unrelated systems to be edited together.</li>
  <li>Rollback is theoretically possible but not practised.</li>
</ul>

<p>A modernization plan should improve the team’s ability to make safe changes, not only replace old code with new code.</p>

<h3 id="2-the-request-and-data-path">2. The request and data path</h3>

<p>Trace one slow or important user workflow through the system:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
</pre></td><td class="rouge-code"><pre>browser or mobile client
        ↓
controller / API endpoint
        ↓
application services and authorization
        ↓
database queries and external services
        ↓
background work, notifications, or reporting
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The goal is not to collect every possible metric. It is to find where the workflow spends time, where failures disappear, and where ownership becomes unclear.</p>

<h3 id="3-background-jobs-and-exports">3. Background jobs and exports</h3>

<p>Reports, imports, notifications, and large exports often expose platform weaknesses first. A synchronous request may appear simple, but it creates a poor failure boundary when the work is large or unpredictable.</p>

<p>A healthier pattern is usually:</p>

<ol>
  <li>Accept the request.</li>
  <li>Enqueue a job.</li>
  <li>Track progress and failure state.</li>
  <li>Store the generated result safely.</li>
  <li>Notify the user when it is ready.</li>
  <li>Provide a controlled download path.</li>
</ol>

<p>The exact implementation depends on the platform, but the architectural question is general: does the user-facing request have to remain open while the system performs work that belongs in an asynchronous workflow?</p>

<h3 id="4-operational-evidence">4. Operational evidence</h3>

<p>When a system is slow or unreliable, the team needs enough evidence to make the next decision.</p>

<p>Useful evidence may include:</p>

<ul>
  <li>Request duration by endpoint or workflow</li>
  <li>Database query timing and query volume</li>
  <li>Job duration, retries, and failure reasons</li>
  <li>Error rates and affected users</li>
  <li>Deployment frequency and rollback history</li>
  <li>Resource saturation during the problem window</li>
</ul>

<p>A dashboard is not automatically observability. Evidence is useful when it helps somebody choose an action.</p>

<h3 id="5-boundaries-between-product-and-platform">5. Boundaries between product and platform</h3>

<p>Learning platforms often combine course content, assessment, reporting, users, integrations, billing, and administration. Over time, these concerns can become tightly coupled.</p>

<p>That does not mean every concern should immediately become a separate service. It does mean the team should identify which boundaries are causing real delivery or operational pain.</p>

<p>A good first boundary may be a reporting workflow, an integration, or a clearly owned domain module—not a wholesale migration to microservices.</p>

<h2 id="modernization-should-reduce-uncertainty">Modernization should reduce uncertainty</h2>

<p>A useful modernization plan answers four questions:</p>

<ol>
  <li><strong>What is hurting the business or users now?</strong></li>
  <li><strong>What evidence supports that conclusion?</strong></li>
  <li><strong>What is the smallest change that could improve it?</strong></li>
  <li><strong>How will we verify whether the change worked?</strong></li>
</ol>

<p>If a proposal cannot answer the fourth question, it is not yet a reliable plan.</p>

<p>The answer may be a code change, a database improvement, better job instrumentation, a deployment safety measure, a clearer ownership boundary, or a decision not to change something yet.</p>

<h2 id="a-practical-first-audit">A practical first audit</h2>

<p>For a small team, a first review can be deliberately narrow:</p>

<ul>
  <li>Choose one painful workflow.</li>
  <li>Review the relevant code and data path.</li>
  <li>Inspect the current tests and deployment path.</li>
  <li>Gather the available performance and error evidence.</li>
  <li>Interview the people who operate or support the workflow.</li>
  <li>Produce a prioritized list of findings.</li>
  <li>Recommend one or two changes that can be verified.</li>
</ul>

<p>This is more useful than producing a generic architecture diagram disconnected from the team’s actual constraints.</p>

<h2 id="what-i-would-avoid">What I would avoid</h2>

<p>I would be cautious about:</p>

<ul>
  <li>Recommending a rewrite before measuring the current problem</li>
  <li>Introducing microservices to solve unclear ownership</li>
  <li>Adding AI where conventional automation would be simpler</li>
  <li>Treating a framework upgrade as a complete modernization strategy</li>
  <li>Building a dashboard without a decision it is meant to support</li>
  <li>Promising a precise outcome before understanding the system</li>
</ul>

<p>The goal is not to defend legacy software. The goal is to make the next decision safer and more valuable.</p>

<h2 id="a-small-next-step">A small next step</h2>

<p>If a Rails or LMS platform is becoming slow, fragile, or difficult to extend, start by writing down one workflow that is causing measurable pain. Record who is affected, how often it happens, what the current workaround is, and what evidence you already have.</p>

<p>That short exercise often reveals whether the next step should be a focused technical investigation, a product decision, an operational improvement, or no major change at all.</p>

<p>I am developing a fixed-scope <a href="/consulting/">Rails/LMS Reliability and Modernization Audit</a> for teams that need this kind of evidence-led first review. The aim is to understand the system and produce a practical roadmap—not to sell a rewrite before the problem is understood.</p>]]></content><author><name>Joey Wang</name></author><category term="engineering" /><category term="ruby-on-rails" /><category term="lms" /><category term="software-architecture" /><category term="performance" /><category term="reliability" /><summary type="html"><![CDATA[A practical way for small education and SaaS teams to diagnose platform friction before committing to a rewrite.]]></summary></entry><entry><title type="html">When Ubuntu Builds Fail on libvips but macOS Does Not</title><link href="https://joeywang.github.io/posts/libvips-ubuntu-build-mismatch/" rel="alternate" type="text/html" title="When Ubuntu Builds Fail on libvips but macOS Does Not" /><published>2026-08-12T21:00:00+00:00</published><updated>2026-08-12T21:00:00+00:00</updated><id>https://joeywang.github.io/posts/libvips-ubuntu-build-mismatch</id><content type="html" xml:base="https://joeywang.github.io/posts/libvips-ubuntu-build-mismatch/"><![CDATA[<h1 id="when-ubuntu-builds-fail-on-libvips-but-macos-does-not">When Ubuntu Builds Fail on libvips but macOS Does Not</h1>

<audio controls="" preload="metadata" src="/assets/audio/libvips-ubuntu-build-mismatch-summary.ogg">
  Your browser does not support the audio element.
</audio>

<p>A Rails test suite can work perfectly on a MacBook and fail before the first example runs on an Ubuntu build machine. That sounds like an application bug, but sometimes the application has not even started yet. The failure is in a native dependency loaded during Rails boot.</p>

<p>I recently hit this with a Rails application using Active Storage and <code class="language-plaintext highlighter-rouge">ruby-vips</code>. The error was essentially:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre>libvips's unfuzzed operations are not safe to use with untrusted content,
and Active Storage cannot disable them.

Disabling them requires libvips 8.13 or later and ruby-vips 2.2.1 or later.
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The Ruby dependency was already new enough. The native library on the Ubuntu host was not.</p>

<p>The important lesson is this:</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">ruby-vips</code> is a Ruby wrapper, not the image-processing engine itself. The wrapper still loads the native <code class="language-plaintext highlighter-rouge">libvips</code> shared library installed by the operating system or by a separate native build.</p>
</blockquote>

<h2 id="the-short-version">The short version</h2>

<p>On the Ubuntu ARM build host used for this investigation, the versions were:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>Ubuntu:     22.04.5 LTS, arm64
libvips:    8.12.1
ruby-vips:  2.3.0
</pre></td></tr></tbody></table></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ruby-vips</code> satisfied the Ruby-side requirement, but Ubuntu’s standard Jammy repository provided libvips 8.12.1. Active Storage requires libvips 8.13 or later for the security behavior it needs, so Rails aborted during initialization.</p>

<p>After installing libvips 8.18.3 under a separate system prefix, the same check reported:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>ruby-vips 2.3.0
libvips 8.18.3
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The focused spec then passed:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>3 examples, 0 failures
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="why-this-happens-on-ubuntu-but-not-on-a-macbook">Why this happens on Ubuntu but not on a MacBook</h2>

<p>This is not because Ubuntu cannot run libvips, and it is not because macOS is inherently more compatible. The difference is usually the package source and release cadence.</p>

<h3 id="ubuntu-lts-favors-stability">Ubuntu LTS favors stability</h3>

<p>Ubuntu LTS releases intentionally keep major system packages stable for the lifetime of the release. On Ubuntu 22.04, the standard ARM repository can therefore provide libvips 8.12.1 even though newer libvips releases exist upstream.</p>

<p>That is a useful server property: security updates and compatibility fixes are preferred over continuously changing feature versions. It also means that a build requiring a newer native library may outgrow the distribution package.</p>

<p>Check the actual package before assuming anything:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre><span class="nb">cat</span> /etc/os-release
<span class="nb">uname</span> <span class="nt">-m</span>
apt-cache policy libvips42 libvips-dev
vips <span class="nt">--version</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>On Debian-family systems, the runtime package is commonly named <code class="language-plaintext highlighter-rouge">libvips42</code> and the headers/build metadata are in <code class="language-plaintext highlighter-rouge">libvips-dev</code>.</p>

<h3 id="homebrew-usually-tracks-newer-software">Homebrew usually tracks newer software</h3>

<p>On macOS, libvips is commonly installed through Homebrew:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>brew <span class="nb">install </span>vips
vips <span class="nt">--version</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Homebrew is a rolling package ecosystem rather than an Ubuntu-LTS snapshot. A MacBook that was set up or updated recently may therefore have a libvips version new enough for the Rails dependency, while an Ubuntu build machine still has the older LTS package.</p>

<p>The right comparison is not “Mac versus Linux.” Compare these instead:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>Ruby version
ruby-vips gem version
native libvips version
architecture
package source
library search path
</pre></td></tr></tbody></table></code></pre></div></div>

<p>A MacBook may also be using a Homebrew-provided ARM64 or x86_64 library, while the Ubuntu builder is using an ARM64 distribution package. Both can be valid installations with different versions.</p>

<h2 id="three-layers-that-are-easy-to-confuse">Three layers that are easy to confuse</h2>

<p>There are three separate pieces in this failure:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
</pre></td><td class="rouge-code"><pre>Rails / Active Storage
        │
        ▼
ruby-vips Ruby gem
        │  FFI / native loading
        ▼
libvips shared library
        │
        ▼
Ubuntu package or source installation
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="1-rails-and-active-storage">1. Rails and Active Storage</h3>

<p>Active Storage uses image-processing backends and checks whether the native library has the security controls it needs. This check can happen while the Rails application boots, which is why the test runner can fail before reporting any examples.</p>

<h3 id="2-the-ruby-vips-gem">2. The <code class="language-plaintext highlighter-rouge">ruby-vips</code> gem</h3>

<p>The gem provides Ruby classes and bindings. Check it through Bundler, not only through the system Ruby:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>bundle info ruby-vips
bundle <span class="nb">exec </span>ruby <span class="nt">-e</span> <span class="s1">'require "vips"; puts Vips::VERSION'</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The project may use a different Ruby from <code class="language-plaintext highlighter-rouge">/usr/bin/ruby</code>, especially when it uses mise, rbenv, or asdf.</p>

<h3 id="3-the-native-libvips-library">3. The native libvips library</h3>

<p>The wrapper loads the shared library found through the dynamic linker. Check the version that the Ruby process actually sees:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>bundle <span class="nb">exec </span>ruby <span class="nt">-e</span> <span class="s1">'require "vips"; puts Vips::LIBRARY_VERSION'</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>This is more useful than checking only <code class="language-plaintext highlighter-rouge">vips --version</code>. The command-line binary and Ruby process can resolve different libraries if <code class="language-plaintext highlighter-rouge">/usr/local</code>, Homebrew, or a custom <code class="language-plaintext highlighter-rouge">LD_LIBRARY_PATH</code> is involved.</p>

<h2 id="the-safest-first-fix-use-the-distribution-package-if-it-is-new-enough">The safest first fix: use the distribution package if it is new enough</h2>

<p>Before compiling anything, check whether the configured Ubuntu repositories already provide a sufficient version:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre><span class="nb">sudo </span>apt update
apt-cache policy libvips42 libvips-dev
</pre></td></tr></tbody></table></code></pre></div></div>

<p>If the candidate is 8.13 or newer, install both the runtime and development packages:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre><span class="nb">sudo </span>apt <span class="nb">install </span>libvips42 libvips-dev
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Then verify both the CLI and the Ruby binding:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>vips <span class="nt">--version</span>
bundle <span class="nb">exec </span>ruby <span class="nt">-e</span> <span class="s1">'require "vips"; puts Vips::LIBRARY_VERSION'</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Installing only <code class="language-plaintext highlighter-rouge">libvips-dev</code> is not enough. The build headers may be present while the runtime still loads the old shared library. Conversely, installing only the runtime package may leave native extensions or build checks without the headers and <code class="language-plaintext highlighter-rouge">pkg-config</code> metadata they need.</p>

<h2 id="building-a-newer-libvips-from-source">Building a newer libvips from source</h2>

<p>When Ubuntu’s package is too old, building a newer upstream release is a practical option for a dedicated build host. The official libvips installation guide recommends Meson and Ninja for source builds.</p>

<p>The following example installs libvips 8.18.3 into <code class="language-plaintext highlighter-rouge">/usr/local</code>. Choose a release that is appropriate for the project and verify its checksum or signature according to your environment’s policy.</p>

<h3 id="install-build-prerequisites">Install build prerequisites</h3>

<p>The exact optional dependencies determine which image formats libvips supports. Start with the core build tools:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
</pre></td><td class="rouge-code"><pre><span class="nb">sudo </span>apt update
<span class="nb">sudo </span>apt <span class="nb">install</span> <span class="se">\</span>
  build-essential <span class="se">\</span>
  meson <span class="se">\</span>
  ninja-build <span class="se">\</span>
  pkg-config <span class="se">\</span>
  libglib2.0-dev <span class="se">\</span>
  libexpat1-dev <span class="se">\</span>
  libjpeg-turbo8-dev <span class="se">\</span>
  libtiff5-dev <span class="se">\</span>
  libpng-dev <span class="se">\</span>
  libwebp-dev <span class="se">\</span>
  libarchive-dev
</pre></td></tr></tbody></table></code></pre></div></div>

<p>You can add development packages for formats your application needs. Inspect the Meson summary carefully; a successful compilation does not necessarily mean every optional image loader was enabled.</p>

<h3 id="download-and-configure-the-release">Download and configure the release</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
</pre></td><td class="rouge-code"><pre><span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/tmp/libvips-build"</span>
<span class="nb">cd</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/tmp/libvips-build"</span>

curl <span class="nt">-fL</span> <span class="nt">--retry</span> 3 <span class="se">\</span>
  <span class="nt">-o</span> libvips-8.18.3.tar.xz <span class="se">\</span>
  https://github.com/libvips/libvips/releases/download/v8.18.3/libvips-8.18.3.tar.xz

<span class="nb">tar</span> <span class="nt">-xf</span> libvips-8.18.3.tar.xz
<span class="nb">cd </span>libvips-8.18.3

meson setup build <span class="se">\</span>
  <span class="nt">--prefix</span><span class="o">=</span>/usr/local <span class="se">\</span>
  <span class="nt">--libdir</span><span class="o">=</span>lib <span class="se">\</span>
  <span class="nt">--buildtype</span><span class="o">=</span>release
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">--libdir=lib</code> option is useful on Debian-family systems because it avoids putting the library under a multiarch path such as <code class="language-plaintext highlighter-rouge">lib/aarch64-linux-gnu</code>. Either layout can work; consistency matters more than the exact choice.</p>

<p>Read the Meson summary before continuing:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>meson configure build
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="compile-test-and-install">Compile, test, and install</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre>meson compile <span class="nt">-C</span> build
meson <span class="nb">test</span> <span class="nt">-C</span> build
<span class="nb">sudo </span>meson <span class="nb">install</span> <span class="nt">-C</span> build
<span class="nb">sudo </span>ldconfig
</pre></td></tr></tbody></table></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ldconfig</code> refreshes the dynamic linker cache so processes can find the newly installed shared library. Without it, the <code class="language-plaintext highlighter-rouge">vips</code> executable or Ruby process may continue loading the distribution copy.</p>

<p>Verify the result:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre>/usr/local/bin/vips <span class="nt">--version</span>
vips <span class="nt">--version</span>

bundle <span class="nb">exec </span>ruby <span class="nt">-e</span> <span class="se">\</span>
  <span class="s1">'require "vips"; puts "ruby-vips #{Vips::VERSION}"; puts "libvips #{Vips::LIBRARY_VERSION}"'</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The important output is the <code class="language-plaintext highlighter-rouge">Vips::LIBRARY_VERSION</code> value observed by the same Bundler environment that runs the Rails tests.</p>

<h2 id="when-ld_library_path-is-needed">When <code class="language-plaintext highlighter-rouge">LD_LIBRARY_PATH</code> is needed</h2>

<p>A custom installation may be in a library directory that is not yet in the linker configuration. For a temporary test, set:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre><span class="nb">export </span><span class="nv">LD_LIBRARY_PATH</span><span class="o">=</span>/usr/local/lib:/usr/local/lib/aarch64-linux-gnu
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Then rerun the Ruby check and the spec:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre><span class="nv">DATABASE_HOST</span><span class="o">=</span>/path/to/local/postgres/socket <span class="se">\</span>
<span class="nv">CI</span><span class="o">=</span><span class="nb">true</span> <span class="se">\</span>
<span class="nv">LD_LIBRARY_PATH</span><span class="o">=</span>/usr/local/lib:/usr/local/lib/<span class="si">$(</span><span class="nb">uname</span> <span class="nt">-m</span><span class="si">)</span><span class="nt">-linux-gnu</span> <span class="se">\</span>
bundle <span class="nb">exec </span>rspec spec/tasks/remove_copy_suffixes_spec.rb
</pre></td></tr></tbody></table></code></pre></div></div>

<p>For a permanent machine configuration, prefer a linker configuration file instead of putting <code class="language-plaintext highlighter-rouge">LD_LIBRARY_PATH</code> into every CI command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre><span class="nb">printf</span> <span class="s1">'%s\n'</span> /usr/local/lib <span class="o">&gt;</span> /tmp/usr-local-lib.conf
<span class="nb">sudo mv</span> /tmp/usr-local-lib.conf /etc/ld.so.conf.d/usr-local-lib.conf
<span class="nb">sudo </span>ldconfig
ldconfig <span class="nt">-p</span> | <span class="nb">grep </span>libvips
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Use the actual directory where <code class="language-plaintext highlighter-rouge">libvips.so</code> was installed. Do not add a path blindly; confirm it first:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>find /usr/local <span class="nt">-name</span> <span class="s1">'libvips.so*'</span> <span class="nt">-print</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="diagnose-which-library-is-being-loaded">Diagnose which library is being loaded</h2>

<p>If the version still looks wrong, inspect the executable and the Ruby process rather than guessing.</p>

<p>For the CLI:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre><span class="nb">command</span> <span class="nt">-v</span> vips
ldd <span class="s2">"</span><span class="si">$(</span><span class="nb">command</span> <span class="nt">-v</span> vips<span class="si">)</span><span class="s2">"</span> | <span class="nb">grep</span> <span class="nt">-E</span> <span class="s1">'vips|glib|expat'</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>For Ruby, first locate the FFI-loaded library and then inspect the process while it is running. A simpler first check is usually enough:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>bundle <span class="nb">exec </span>ruby <span class="nt">-e</span> <span class="s1">'require "vips"; abort "old libvips" unless Vips::LIBRARY_VERSION &gt;= "8.13"; puts Vips::LIBRARY_VERSION'</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Also check for multiple installations:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>find /usr /usr/local <span class="nt">-name</span> <span class="s1">'libvips.so*'</span> <span class="nt">-o</span> <span class="nt">-name</span> <span class="s1">'vips'</span> 2&gt;/dev/null
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Common causes of a mismatch include:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">/usr/bin/vips</code> is from apt but Ruby loads <code class="language-plaintext highlighter-rouge">/usr/local/lib</code>.</li>
  <li>The new library was installed but <code class="language-plaintext highlighter-rouge">ldconfig</code> was not run.</li>
  <li><code class="language-plaintext highlighter-rouge">LD_LIBRARY_PATH</code> points to an older build.</li>
  <li>The shell uses one Ruby while Bundler uses another.</li>
  <li>The build host is ARM64 but a copied binary or package targets another architecture.</li>
</ul>

<h2 id="re-run-rails-at-the-same-layer-as-ci">Re-run Rails at the same layer as CI</h2>

<p>Once the native version is correct, run the smallest failing spec first:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre><span class="nv">DATABASE_HOST</span><span class="o">=</span>/path/to/local/postgres/socket <span class="se">\</span>
<span class="nv">CI</span><span class="o">=</span><span class="nb">true</span> <span class="se">\</span>
<span class="nv">LD_LIBRARY_PATH</span><span class="o">=</span>/usr/local/lib:/usr/local/lib/<span class="si">$(</span><span class="nb">uname</span> <span class="nt">-m</span><span class="si">)</span><span class="nt">-linux-gnu</span> <span class="se">\</span>
bundle <span class="nb">exec </span>rspec spec/tasks/remove_copy_suffixes_spec.rb
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Then run the repository’s normal test command. Keep the environment explicit so a local interactive shell does not hide a missing CI dependency.</p>

<p>A successful native check does not prove the whole application is correct. It only proves that the process can load a compatible libvips. The Rails test suite still needs its database, browser, JavaScript, and application-level dependencies.</p>

<h2 id="should-libvips-be-upgraded-on-every-ubuntu-machine">Should libvips be upgraded on every Ubuntu machine?</h2>

<p>No. There are several reasonable strategies:</p>

<h3 id="pin-a-newer-os-package">Pin a newer OS package</h3>

<p>Best when the organization has a trusted repository or image-building process that supplies the required version. It keeps upgrades integrated with package management, but introduces repository and support decisions.</p>

<h3 id="build-libvips-into-the-ci-image">Build libvips into the CI image</h3>

<p>Best when CI is containerized or image-based. The native version becomes part of a reproducible builder image instead of an undocumented manual server change.</p>

<h3 id="build-into-a-private-prefix">Build into a private prefix</h3>

<p>Best for a single dedicated host where you want to avoid replacing Ubuntu-managed files. <code class="language-plaintext highlighter-rouge">/usr/local</code> is separate from <code class="language-plaintext highlighter-rouge">/usr</code>, and the install can be removed or replaced deliberately. Document the linker path and the exact source release.</p>

<h3 id="upgrade-ubuntu">Upgrade Ubuntu</h3>

<p>Best when the build host is already due for an OS refresh. A newer Ubuntu release may provide a newer libvips package, but an OS upgrade is a much larger change than a single library upgrade.</p>

<p>The least desirable option is to overwrite files owned by apt under <code class="language-plaintext highlighter-rouge">/usr/lib</code> manually. That makes future package upgrades and incident recovery harder.</p>

<h2 id="a-repeatable-verification-checklist">A repeatable verification checklist</h2>

<p>When the MacBook passes and Ubuntu fails, record this information on both machines:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre><span class="nb">uname</span> <span class="nt">-m</span>
ruby <span class="nt">--version</span>
bundle <span class="nt">--version</span>
bundle info ruby-vips
bundle <span class="nb">exec </span>ruby <span class="nt">-e</span> <span class="s1">'require "vips"; puts Vips::VERSION; puts Vips::LIBRARY_VERSION'</span>
vips <span class="nt">--version</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Then compare:</p>

<ol>
  <li>Ruby and Bundler versions.</li>
  <li><code class="language-plaintext highlighter-rouge">ruby-vips</code> gem version.</li>
  <li>Native <code class="language-plaintext highlighter-rouge">libvips</code> version loaded by Ruby.</li>
  <li>CPU architecture.</li>
  <li>Package manager and package source.</li>
  <li>Dynamic-library search paths.</li>
  <li>The exact test command and environment variables.</li>
</ol>

<p>This turns “it only fails on Ubuntu” into a concrete dependency difference.</p>

<h2 id="final-thoughts">Final thoughts</h2>

<p>Native dependencies live below the Ruby dependency file. Bundler can resolve <code class="language-plaintext highlighter-rouge">ruby-vips</code> successfully while the operating system still provides an incompatible libvips. macOS and Ubuntu can therefore run the same Rails code with different native behavior because their package ecosystems move at different speeds.</p>

<p>The durable fix is not to disable the safety check. It is to make the build environment explicit: use a compatible native libvips, verify the version from inside the Bundler process, install it through a repeatable package or image workflow, and document the linker configuration.</p>

<p>Once those layers are checked separately, the failure is straightforward: the application was not broken; the Ubuntu builder was loading an older native library than the Rails dependency allowed.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://www.libvips.org/install.html">libvips installation guide</a></li>
  <li><a href="https://github.com/libvips/libvips/releases">libvips releases</a></li>
  <li><a href="https://github.com/libvips/libvips/releases/tag/v8.18.3">libvips 8.18.3 release notes</a></li>
  <li><a href="https://github.com/libvips/ruby-vips">ruby-vips</a></li>
</ul>]]></content><author><name>Joey Wang</name></author><category term="engineering" /><category term="devops" /><category term="ruby" /><category term="rails" /><category term="libvips" /><category term="ubuntu" /><category term="macos" /><category term="testing" /><category term="native-dependencies" /><summary type="html"><![CDATA[Why a Rails test suite can fail because Ubuntu ships an older native libvips, how ruby-vips fits into the picture, and how to upgrade libvips safely without confusing the system package with the Ruby gem.]]></summary></entry><entry><title type="html">A Portable Workflow for Testing Small Local LLMs on Modest Hardware</title><link href="https://joeywang.github.io/posts/portable-local-llm-benchmark/" rel="alternate" type="text/html" title="A Portable Workflow for Testing Small Local LLMs on Modest Hardware" /><published>2026-08-12T17:00:00+00:00</published><updated>2026-08-12T17:00:00+00:00</updated><id>https://joeywang.github.io/posts/portable-local-llm-benchmark</id><content type="html" xml:base="https://joeywang.github.io/posts/portable-local-llm-benchmark/"><![CDATA[<h1 id="a-portable-workflow-for-testing-small-local-llms-on-modest-hardware">A Portable Workflow for Testing Small Local LLMs on Modest Hardware</h1>

<audio controls="" preload="metadata" src="/assets/audio/portable-local-llm-benchmark-summary.ogg">
  Your browser does not support the audio element.
</audio>

<p>I wanted a local model that could handle routine Hermes work on a small ARM server: 12 GB of memory and two virtual CPUs. The target was not a leaderboard score. I wanted to know whether the model could follow short instructions, return JSON, write a small function, fix a bug, and produce a short plan without exhausting the machine.</p>

<p>The same question comes up whenever hardware changes. A model that feels good on a 16 GB Apple Silicon laptop may behave very differently on a two-core Linux VM. Rather than asking whether a model needs a fixed amount of memory, I wanted a measurement process I could repeat on the next machine.</p>

<h2 id="the-setup">The setup</h2>

<p>The server used for this test had:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>Architecture: aarch64
CPU:         2 x Neoverse-N1 OCPU
Memory:      11 GiB usable
Swap:        8 GiB
Runtime:     llama.cpp / llama-server
Quantization: Q4_K_M GGUF
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The model server exposed an OpenAI-compatible endpoint on loopback:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>http://127.0.0.1:18080/v1
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Binding to loopback matters. This was a local experiment, not an internet-facing inference service. An endpoint that accepts prompts and may return private code should not be exposed unnecessarily.</p>

<p>The llama.cpp command was deliberately simple:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
</pre></td><td class="rouge-code"><pre>llama-server <span class="se">\</span>
  <span class="nt">-m</span> /path/to/model.gguf <span class="se">\</span>
  <span class="nt">--alias</span> local-model <span class="se">\</span>
  <span class="nt">--host</span> 127.0.0.1 <span class="se">\</span>
  <span class="nt">--port</span> 18080 <span class="se">\</span>
  <span class="nt">-c</span> 32768 <span class="se">\</span>
  <span class="nt">-t</span> 2 <span class="se">\</span>
  <span class="nt">-np</span> 1 <span class="se">\</span>
  <span class="nt">--cache-type-k</span> q8_0 <span class="se">\</span>
  <span class="nt">--cache-type-v</span> q8_0 <span class="se">\</span>
  <span class="nt">--metrics</span> <span class="se">\</span>
  <span class="nt">--no-webui</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The exact flags are not universal. The important parts are the model path, loopback binding, one request slot, a context size that fits the machine, and metrics that let the test record speed rather than relying on impressions.</p>

<h2 id="keep-the-model-download-separate-from-the-benchmark">Keep the model download separate from the benchmark</h2>

<p>I use the Hugging Face CLI to download one named file into a predictable directory:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre><span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/local-llm/models/Qwen3-4B-Instruct-2507-GGUF"</span>

hf download <span class="se">\</span>
  bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF <span class="se">\</span>
  Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf <span class="se">\</span>
  <span class="nt">--local-dir</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/local-llm/models/Qwen3-4B-Instruct-2507-GGUF"</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>A repeatable directory layout makes the server command portable:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre>local-llm/
├── models/
│   └── &lt;model-name&gt;/
│       └── &lt;quantization&gt;.gguf
├── src/llama.cpp/
├── start-&lt;model-name&gt;.sh
└── (optional) hermes-local wrapper
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Download only the quantization you intend to test. A model repository often contains many variants, and downloading a whole repository makes it harder to know which file was actually measured.</p>

<h2 id="always-check-the-runtime-before-loading-a-model">Always check the runtime before loading a model</h2>

<p>Before starting anything, record the hardware and check for an existing server:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre><span class="nb">uname</span> <span class="nt">-m</span>
<span class="nb">nproc
</span>lscpu | <span class="nb">grep</span> <span class="nt">-E</span> <span class="s1">'Architecture|Model name|CPU\\(s\\)'</span> | <span class="nb">head
</span>free <span class="nt">-h</span>
ps <span class="nt">-eo</span> pid,etime,%cpu,%mem,rss,cmd | <span class="nb">grep</span> <span class="nt">-E</span> <span class="s1">'llama-server|ollama|mlx'</span> | <span class="nb">grep</span> <span class="nt">-v</span> <span class="nb">grep</span> <span class="o">||</span> <span class="nb">true</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Then start the server and wait for its health endpoint:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre>./start-qwen3.sh &amp;

<span class="k">until </span>curl <span class="nt">-fsS</span> http://127.0.0.1:18080/health | <span class="nb">grep</span> <span class="nt">-q</span> <span class="s1">'"status":"ok"'</span><span class="p">;</span> <span class="k">do
  </span><span class="nb">sleep </span>2
<span class="k">done</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Do not treat a process that exists as a ready model. llama-server can spend a while loading weights. During that period, <code class="language-plaintext highlighter-rouge">/health</code> returns a 503 loading response. The benchmark should start only after the endpoint reports healthy.</p>

<p>After loading, check memory again:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>ps <span class="nt">-o</span> pid,etime,%cpu,%mem,rss,vsz,cmd <span class="nt">-C</span> llama-server
free <span class="nt">-h</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The file size is not the memory footprint. The runtime also needs model metadata, working buffers, the KV cache, and the context window. On this machine, a 2.5 GB Q4 model used roughly 7.5 GB RSS with a 32K context.</p>

<h2 id="test-capabilities-not-just-speed">Test capabilities, not just speed</h2>

<p>A useful smoke benchmark should exercise the tasks the local model is expected to do. I used seven small cases:</p>

<ol>
  <li>Exact instruction following</li>
  <li>JSON-only structured output</li>
  <li>Arithmetic</li>
  <li>A small logic puzzle</li>
  <li>Writing a Python function</li>
  <li>Fixing an empty-list bug</li>
  <li>Producing a four-step implementation plan</li>
</ol>

<p>The test sends each case through the OpenAI-compatible chat completions endpoint with temperature zero and checks the result programmatically:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre><span class="n">payload</span> <span class="o">=</span> <span class="p">{</span>
    <span class="sh">"</span><span class="s">model</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">local-model</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">messages</span><span class="sh">"</span><span class="p">:</span> <span class="p">[{</span><span class="sh">"</span><span class="s">role</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">user</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">content</span><span class="sh">"</span><span class="p">:</span> <span class="n">prompt</span><span class="p">}],</span>
    <span class="sh">"</span><span class="s">temperature</span><span class="sh">"</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">max_tokens</span><span class="sh">"</span><span class="p">:</span> <span class="n">max_tokens</span><span class="p">,</span>
<span class="p">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The checks should be explicit. For example, the bug-fixing case should verify that the response contains a guard for an empty list and returns zero. A human can see that two answers are equivalent, but a script makes the result reproducible and exposes formatting differences.</p>

<p>The benchmark also records:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>wall time
prompt tokens and prompt tokens/sec
generated tokens and generation tokens/sec
model output
finish reason
errors from the checker
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The raw output belongs beside the script. A summary without the individual responses is not enough to diagnose a failure.</p>

<h2 id="the-reasoning-token-trap">The reasoning-token trap</h2>

<p>One of the first results looked like a very poor E2B score. Several answers were empty even though the model was clearly working. Inspecting the raw response showed that the model had filled the completion budget with <code class="language-plaintext highlighter-rouge">reasoning_content</code> and never reached a visible answer.</p>

<p>That is a serving and evaluation issue, not a clean measure of model capability. Small reasoning models need separate handling for:</p>

<ul>
  <li>hidden or visible reasoning content;</li>
  <li>the token budget allocated to reasoning;</li>
  <li>the final answer budget;</li>
  <li>stop conditions and end-of-turn markers.</li>
</ul>

<p>A test that only reads <code class="language-plaintext highlighter-rouge">message.content</code> can report a false failure when the useful answer is in another response field. Conversely, a model that regularly consumes its entire budget thinking is still a poor Hermes fallback if the agent runtime cannot handle that behavior reliably.</p>

<p>For a fair comparison, either disable extended thinking for short operational tasks or allocate enough tokens for both reasoning and the final answer. Then run the same test again.</p>

<h2 id="results-on-the-two-ocpu-server">Results on the two-OCPU server</h2>

<p>The first comparison included Gemma 4 E2B and E4B, followed by three smaller general or coding models. These were observed results from this machine, not claims about every runtime or quantization.</p>

<table>
  <thead>
    <tr>
      <th>Model</th>
      <th style="text-align: right">GGUF file</th>
      <th style="text-align: right">Raw pass rate</th>
      <th style="text-align: right">Bug fix</th>
      <th style="text-align: right">Generation speed</th>
      <th style="text-align: right">Peak RSS</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Gemma 4 E2B</td>
      <td style="text-align: right">3.11 GB</td>
      <td style="text-align: right">1/7</td>
      <td style="text-align: right">Failed in this setup</td>
      <td style="text-align: right">8.7 tok/s</td>
      <td style="text-align: right">4.7 GB</td>
    </tr>
    <tr>
      <td>Gemma 4 E4B</td>
      <td style="text-align: right">4.98 GB</td>
      <td style="text-align: right">4/7</td>
      <td style="text-align: right">Passed</td>
      <td style="text-align: right">4.7 tok/s</td>
      <td style="text-align: right">7.9 GB</td>
    </tr>
    <tr>
      <td>Qwen2.5-Coder 3B</td>
      <td style="text-align: right">1.93 GB</td>
      <td style="text-align: right">3/7</td>
      <td style="text-align: right">Passed</td>
      <td style="text-align: right">8.6 tok/s</td>
      <td style="text-align: right">about 7.2 GB</td>
    </tr>
    <tr>
      <td>Phi-4-mini</td>
      <td style="text-align: right">2.49 GB</td>
      <td style="text-align: right">3/7</td>
      <td style="text-align: right">Semantically passed, formatting failed</td>
      <td style="text-align: right">5.8 tok/s</td>
      <td style="text-align: right">about 7.2 GB</td>
    </tr>
    <tr>
      <td>Qwen3-4B-Instruct-2507</td>
      <td style="text-align: right">2.50 GB</td>
      <td style="text-align: right">5/7</td>
      <td style="text-align: right">Passed</td>
      <td style="text-align: right">5.9 tok/s</td>
      <td style="text-align: right">about 7.5 GB</td>
    </tr>
  </tbody>
</table>

<p>The score is only one signal. Qwen2.5-Coder produced correct code but wrapped it in Markdown fences, so a strict code-only checker marked it wrong. Phi-4 calculated the right arithmetic result but was cut off before completing the response. Qwen3 returned valid JSON, passed the bug-fix and planning cases, and was the best general candidate in this run.</p>

<p>The same E2B family model performs differently on a 16 GB MacBook using the MLX runtime. There, E2B reached 6/7, missing only the planning case. That comparison is useful, but it does not contradict the Linux result. The machines differ in CPU, memory bandwidth, runtime, quantization format, prompt template handling, and response parsing.</p>

<h2 id="choosing-always-on-versus-on-demand">Choosing always-on versus on-demand</h2>

<p>The decision should use both capability and resource headroom.</p>

<p>An always-on model must leave room for the agent runtime, the operating system, logs, Telegram or gateway processes, and temporary command output. A model that uses nearly all available memory may work in a quiet benchmark and then trigger swapping during a real coding task.</p>

<p>For this 12 GB class machine, I would use these practical bands:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre>under 5 GB RSS:     plausible always-on candidate
5–7 GB RSS:         test carefully with the real agent workload
7–9 GB RSS:         prefer on-demand or reduce context
above 9 GB RSS:     unsafe for a shared host
</pre></td></tr></tbody></table></code></pre></div></div>

<p>These are operating guidelines, not model requirements. Context size can move a model between bands. A 16K context may be a better default than 32K when the model is handling short coding tasks.</p>

<p>For an on-demand workflow:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre>./start-qwen3.sh
<span class="c"># wait for /health to report status=ok</span>
hermes chat <span class="nt">--model</span> local-qwen3 ...
<span class="c"># stop the server after the task</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>For an always-on fallback, add a supervised service only after measuring idle memory, request memory, startup behavior, and recovery from a failed request. Do not turn a benchmark command into a system service before those checks pass.</p>

<h2 id="make-the-workflow-portable">Make the workflow portable</h2>

<p>The reusable part is a small model manifest plus one benchmark runner. For each candidate, record:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre><span class="na">name</span><span class="pi">:</span> <span class="s">qwen3-4b-instruct-2507</span>
<span class="na">source</span><span class="pi">:</span> <span class="s">bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF</span>
<span class="na">file</span><span class="pi">:</span> <span class="s">Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf</span>
<span class="na">alias</span><span class="pi">:</span> <span class="s">local-qwen3</span>
<span class="na">context</span><span class="pi">:</span> <span class="m">16384</span>
<span class="na">temperature</span><span class="pi">:</span> <span class="m">0</span>
<span class="na">max_tokens</span><span class="pi">:</span> <span class="m">256</span>
<span class="na">expected_use</span><span class="pi">:</span> <span class="s">routine coding fallback</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The runner should accept the model path and alias rather than embedding one model name in the test. Run candidates sequentially, not concurrently, unless the goal is specifically to measure multi-model contention. Save one JSONL file per run with the hardware summary and llama.cpp version.</p>

<p>Keep the following separate:</p>

<ul>
  <li>model acquisition;</li>
  <li>server startup and health checks;</li>
  <li>capability tests;</li>
  <li>memory and throughput measurement;</li>
  <li>Hermes integration tests;</li>
  <li>policy for escalation.</li>
</ul>

<p>This separation prevents a failing download, a bad chat template, and a weak model from all looking like the same problem.</p>

<h2 id="give-the-local-model-an-explicit-command">Give the local model an explicit command</h2>

<p>Once the local server is healthy, the most useful operational improvement is a command that makes model selection explicit. Relying on the normal Hermes routing path is convenient, but it can obscure whether a task actually ran locally or went to a cloud fallback.</p>

<p>I added a <code class="language-plaintext highlighter-rouge">hermes-local</code> wrapper for tasks that must run on the persistent local worker:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>hermes-local <span class="s2">"Reply exactly LOCAL_OK"</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The wrapper pins the request to the <code class="language-plaintext highlighter-rouge">local-qwen3</code> model and checks the loopback endpoint before starting Hermes. It deliberately does <strong>not</strong> fall back to a cloud model. If Qwen3 is unavailable, the command fails clearly instead of silently changing the execution environment.</p>

<p>For a bounded implementation task in an isolated worktree:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre>hermes-local <span class="se">\</span>
  <span class="nt">--in</span> /path/to/isolated-worktree <span class="se">\</span>
  <span class="nt">--max-turns</span> 20 <span class="se">\</span>
  <span class="s2">"Implement the prepared task in IMPLEMENTATION.md and run its verification command."</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The command defaults to the <code class="language-plaintext highlighter-rouge">file,terminal</code> toolsets and no extended reasoning. Those defaults are intentional: the local model is an implementer, not the architecture owner. The cloud model should write the specification, while the local worker performs the small edit and deterministic test run.</p>

<p>A safe delegation contract has three parts:</p>

<ol>
  <li><strong>Cloud model:</strong> inspect the repository and write a precise <code class="language-plaintext highlighter-rouge">IMPLEMENTATION.md</code>.</li>
  <li><strong>Local model:</strong> edit only named files and run the named verification command.</li>
  <li><strong>Cloud model:</strong> inspect the diff, repeat broader tests, and make PR, merge, or deployment decisions.</li>
</ol>

<p>This separation makes the local model useful without pretending that a small CPU model should own ambiguous debugging, security review, architecture, or production operations.</p>

<h2 id="the-test-that-matters-after-the-smoke-test">The test that matters after the smoke test</h2>

<p>A seven-prompt benchmark is a filter. It is not proof that a model can run a coding agent.</p>

<p>The next test should use a temporary repository and ask the model to:</p>

<ol>
  <li>inspect the repository;</li>
  <li>implement a small change;</li>
  <li>add or update tests;</li>
  <li>run the tests;</li>
  <li>report the changed files and verification result.</li>
</ol>

<p>The runner should capture the complete transcript, tool calls, command results, elapsed time, and final diff. It should fail if the model claims success without a real passing test command.</p>

<p>That test measures the agent loop as well as the model. A model can pass direct prompts and still fail when it needs to plan, call a tool, consume the result, and continue for several turns.</p>

<h2 id="what-i-would-use">What I would use</h2>

<p>On this two-OCPU, 12 GB machine, Qwen3-4B-Instruct-2507 is the best general candidate from this small comparison. I would start it with a 16K context, keep it on-demand, and test the real Hermes implementation workflow before making it a permanent fallback.</p>

<p>On an Apple Silicon laptop with 16 GB unified memory, Gemma 4 E2B through MLX is a reasonable choice if the local runtime produces the stronger result observed there. The model name alone does not determine the result. The runtime and hardware are part of the deployment.</p>

<p>The workflow is the part worth keeping:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
</pre></td><td class="rouge-code"><pre>inspect the host
  → download one exact quantization
  → start a loopback server
  → wait for a real health signal
  → pin tasks explicitly with hermes-local
  → run fixed capability checks
  → record output, speed, and memory
  → inspect reasoning-channel behavior
  → run a real tool-loop task
  → choose always-on or on-demand
</pre></td></tr></tbody></table></code></pre></div></div>

<p>That process can be copied to a laptop, VM, ARM board, or larger server. The answer will change with the resources. The measurement method does not have to.</p>]]></content><author><name>Joey Wang</name></author><category term="ai" /><category term="engineering" /><category term="local-ai" /><category term="llm" /><category term="gguf" /><category term="llama-cpp" /><category term="hermes" /><category term="coding-agent" /><category term="benchmarking" /><summary type="html"><![CDATA[A repeatable way to download, serve, benchmark, and choose small GGUF models for Hermes and coding-agent work across different CPU and memory budgets.]]></summary></entry><entry><title type="html">Orchestrating Hermes with Local Gemma: Cheap Routing, Delegation, and Safe Rails Workflows</title><link href="https://joeywang.github.io/posts/orchestrating-hermes-with-local-gemma/" rel="alternate" type="text/html" title="Orchestrating Hermes with Local Gemma: Cheap Routing, Delegation, and Safe Rails Workflows" /><published>2026-08-12T13:00:00+00:00</published><updated>2026-08-12T13:00:00+00:00</updated><id>https://joeywang.github.io/posts/orchestrating-hermes-with-local-gemma</id><content type="html" xml:base="https://joeywang.github.io/posts/orchestrating-hermes-with-local-gemma/"><![CDATA[<h1 id="orchestrating-hermes-with-local-gemma">Orchestrating Hermes with Local Gemma</h1>

<p><em>Part of the <a href="/ai-agents/">AI &amp; Agents reading path</a>.</em></p>

<audio controls="" preload="metadata" src="/assets/audio/orchestrating-hermes-local-gemma-summary.ogg">
  Your browser does not support the audio element.
</audio>

<p>A useful coding agent is more than a language model with a terminal attached. It needs a workspace, tools, approval boundaries, a way to delegate, and a recovery plan for the cases where the first attempt stalls.</p>

<p>This post documents my current Hermes setup on an Apple Silicon Mac and the practical lesson from the experiment:</p>

<blockquote>
  <p><strong>Local Gemma is already good at deciding which engineering workflow a request needs. Automatic per-request model switching is not yet active in this Hermes installation, so explicit local-session selection is the reliable path today.</strong></p>
</blockquote>

<p>The examples use a Rails application under a placeholder path. They are written to be safe defaults: inspect first, show the plan, approve dependency changes, then verify the result.</p>

<h2 id="the-architecture">The architecture</h2>

<p>The setup has three model paths:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
</pre></td><td class="rouge-code"><pre>┌──────────────────────────────────────────────────────────┐
│ Main Hermes conversation                                 │
│ Cloud model when broad reasoning or difficult context    │
└───────────────────────┬──────────────────────────────────┘
                        │ delegation, when requested/used
                        ▼
┌──────────────────────────────────────────────────────────┐
│ Local Gemma through LM Studio                           │
│ google/gemma-4-e2b                                      │
│ http://127.0.0.1:1234/v1                                │
└───────────────────────┬──────────────────────────────────┘
                        │ file + terminal tools
                        ▼
┌──────────────────────────────────────────────────────────┐
│ Local project workspace                                  │
│ /path/to/&lt;application&gt;                                   │
└──────────────────────────────────────────────────────────┘
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Hermes is the runtime. LM Studio provides an OpenAI-compatible local endpoint. Gemma makes decisions and proposes actions. Hermes, not Gemma, executes tools and applies approval policy.</p>

<p>That separation matters. A model does not run <code class="language-plaintext highlighter-rouge">bundle update</code> by itself. The agent runtime executes the command, captures the output, gives it back to the model, and continues the loop.</p>

<h2 id="runtime-details">Runtime details</h2>

<p>The local endpoint and model currently used are:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>Endpoint: http://127.0.0.1:1234/v1
Model:    google/gemma-4-e2b
Runtime:  LM Studio, local device
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The Hermes configuration contains the local provider and alias:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
</pre></td><td class="rouge-code"><pre><span class="na">fallback_providers</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">provider</span><span class="pi">:</span> <span class="s">custom</span>
    <span class="na">model</span><span class="pi">:</span> <span class="s">google/gemma-4-e2b</span>
    <span class="na">base_url</span><span class="pi">:</span> <span class="s">http://127.0.0.1:1234/v1</span>
    <span class="na">api_mode</span><span class="pi">:</span> <span class="s">chat_completions</span>

<span class="na">delegation</span><span class="pi">:</span>
  <span class="na">model</span><span class="pi">:</span> <span class="s">google/gemma-4-e2b</span>
  <span class="na">provider</span><span class="pi">:</span> <span class="s">custom</span>
  <span class="na">base_url</span><span class="pi">:</span> <span class="s">http://127.0.0.1:1234/v1</span>

<span class="na">model_aliases</span><span class="pi">:</span>
  <span class="na">local-gemma</span><span class="pi">:</span>
    <span class="na">provider</span><span class="pi">:</span> <span class="s">custom</span>
    <span class="na">model</span><span class="pi">:</span> <span class="s">google/gemma-4-e2b</span>
    <span class="na">base_url</span><span class="pi">:</span> <span class="s">http://127.0.0.1:1234/v1</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Check the effective configuration rather than assuming it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre>hermes config path
hermes config get fallback_providers
hermes config get delegation
hermes config get model_aliases
hermes config check
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Check LM Studio independently:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>lms <span class="nb">ls
</span>lms ps
curl <span class="nt">-sS</span> http://127.0.0.1:1234/v1/models
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The endpoint should list <code class="language-plaintext highlighter-rouge">google/gemma-4-e2b</code>. If it does not, Hermes cannot use the local model even if the YAML looks correct.</p>

<h2 id="the-simplest-cheap-workflow-run-the-whole-session-locally">The simplest cheap workflow: run the whole session locally</h2>

<p>The most predictable form of cost control is not automatic routing. It is selecting the local model explicitly for a session:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre>hermes chat <span class="se">\</span>
  <span class="nt">--model</span> local-gemma <span class="se">\</span>
  <span class="nt">--in</span> <span class="s2">"/path/to/my-rails-app"</span> <span class="se">\</span>
  <span class="nt">--toolsets</span> file,terminal <span class="se">\</span>
  <span class="nt">--reasoning</span> low
</pre></td></tr></tbody></table></code></pre></div></div>

<p>For a one-shot inspection:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>hermes chat <span class="se">\</span>
  <span class="nt">--model</span> local-gemma <span class="se">\</span>
  <span class="nt">--in</span> <span class="s2">"/path/to/my-rails-app"</span> <span class="se">\</span>
  <span class="nt">--toolsets</span> file,terminal <span class="se">\</span>
  <span class="nt">--reasoning</span> low <span class="se">\</span>
  <span class="nt">-q</span> <span class="s1">'Inspect this Rails app. Identify the package managers, current dependency state, test commands, and any uncommitted changes. Do not modify files or run upgrades.'</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>This makes the model choice visible and auditable. It also avoids accidentally sending a routine maintenance request to an expensive cloud provider.</p>

<p>Use the real application path in place of <code class="language-plaintext highlighter-rouge">/path/to/my-rails-app</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre><span class="nb">printf</span> <span class="s1">'%s\n'</span> /path/to/projects/<span class="k">*</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Do not assume that every directory under <code class="language-plaintext highlighter-rouge">/path/to/projects</code> is a Rails application. Let the agent inspect for <code class="language-plaintext highlighter-rouge">Gemfile</code>, <code class="language-plaintext highlighter-rouge">Gemfile.lock</code>, <code class="language-plaintext highlighter-rouge">package.json</code>, <code class="language-plaintext highlighter-rouge">yarn.lock</code>, <code class="language-plaintext highlighter-rouge">config/application.rb</code>, and the repository state.</p>

<h2 id="example-bundler-and-yarn-upgrade-in-a-rails-app">Example: Bundler and Yarn upgrade in a Rails app</h2>

<p>A dependency upgrade is not a trivial “run two commands” task. It can change a lockfile, update transitive dependencies, expose incompatible Ruby versions, alter JavaScript build output, or require application changes.</p>

<p>Start with a read-only request:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>hermes chat <span class="se">\</span>
  <span class="nt">--model</span> local-gemma <span class="se">\</span>
  <span class="nt">--in</span> <span class="s2">"/path/to/my-rails-app"</span> <span class="se">\</span>
  <span class="nt">--toolsets</span> file,terminal <span class="se">\</span>
  <span class="nt">--reasoning</span> low <span class="se">\</span>
  <span class="nt">-q</span> <span class="s1">'Inspect this Rails repository for a routine dependency maintenance task. Determine whether it uses Bundler and Yarn, report the Ruby/Rails/Node/package-manager versions, inspect git status, identify the relevant upgrade commands, and propose a verification plan. Do not modify files and do not run bundle or yarn upgrade commands.'</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>A good response should first discover facts. Typical inspection commands include:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
</pre></td><td class="rouge-code"><pre><span class="nb">pwd
</span>git status <span class="nt">--short</span> <span class="nt">--branch</span>
ruby <span class="nt">--version</span>
bundle <span class="nt">--version</span>
node <span class="nt">--version</span>
yarn <span class="nt">--version</span>
bundle check
bundle outdated <span class="nt">--only-explicit</span> <span class="o">||</span> <span class="nb">true
</span>yarn outdated <span class="o">||</span> <span class="nb">true</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The exact commands depend on the repository. In particular, do not use Yarn if the project actually uses npm or pnpm, and do not assume that <code class="language-plaintext highlighter-rouge">bundle update</code> is equivalent to updating one named gem.</p>

<p>Once the plan is reviewed, run a separate approved session or continue the same session with a clear boundary:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>hermes chat <span class="se">\</span>
  <span class="nt">--model</span> local-gemma <span class="se">\</span>
  <span class="nt">--in</span> <span class="s2">"/path/to/my-rails-app"</span> <span class="se">\</span>
  <span class="nt">--toolsets</span> file,terminal <span class="se">\</span>
  <span class="nt">--reasoning</span> low <span class="se">\</span>
  <span class="nt">-q</span> <span class="s1">'Now apply the approved dependency-maintenance plan. Update only the agreed Bundler and Yarn dependencies. Before each mutating command, show the exact command and its scope. Afterward inspect the diff and run the repository verification commands. Do not deploy, push, delete files, or change application code unless explicitly required and approved.'</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The desired workflow is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre>inspect
  → identify package managers and scope
  → show exact commands
  → approve
  → update dependencies
  → inspect lockfile diff
  → run tests and build checks
  → report failures and remaining risk
</pre></td></tr></tbody></table></code></pre></div></div>

<p>For a narrow update, the commands may look like these, but the agent should verify the project first:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>bundle update rails

yarn upgrade-interactive <span class="nt">--latest</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>A broad upgrade is much riskier:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>bundle update
yarn upgrade
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Do not use broad commands merely because they are shorter. Lockfile churn makes failures harder to diagnose and rollbacks less focused.</p>

<h2 id="approval-boundaries">Approval boundaries</h2>

<p>Keep normal Hermes approvals enabled. Avoid <code class="language-plaintext highlighter-rouge">--yolo</code> for dependency work:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre><span class="c"># Safer default: approval prompts remain active</span>
hermes chat <span class="nt">--model</span> local-gemma <span class="nt">--in</span> <span class="s2">"/path/to/my-rails-app"</span>

<span class="c"># Avoid this for upgrades unless you have a very specific reason:</span>
<span class="c"># hermes chat --yolo ...</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>A useful prompt makes the boundary explicit:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>You may inspect files and run read-only diagnostics. Do not run mutating commands until I approve the exact command. Treat bundle update, yarn upgrade, database migrations, deployment, git commit, git push, file deletion, and changes outside this repository as mutating operations.
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The local model is inexpensive, but an unsafe command is still unsafe. Lower inference cost should buy more inspection and smaller changes, not fewer controls.</p>

<h2 id="what-delegation-means-in-hermes">What delegation means in Hermes</h2>

<p>Delegation is different from automatic routing.</p>

<ul>
  <li><strong>Explicit model selection</strong> chooses the model for the current Hermes session.</li>
  <li><strong>Delegation</strong> lets the main agent ask a configured child model to handle a subtask.</li>
  <li><strong>Fallback</strong> is provider failover when the primary model is unavailable.</li>
  <li><strong>Smart routing</strong> would classify each request and choose a cheaper model automatically, but that feature is not active in this installed Hermes runtime.</li>
</ul>

<p>The configured delegation model can be checked with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>hermes config get delegation
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The important fields are:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
</pre></td><td class="rouge-code"><pre><span class="na">delegation</span><span class="pi">:</span>
  <span class="na">model</span><span class="pi">:</span> <span class="s">google/gemma-4-e2b</span>
  <span class="na">provider</span><span class="pi">:</span> <span class="s">custom</span>
  <span class="na">base_url</span><span class="pi">:</span> <span class="s">http://127.0.0.1:1234/v1</span>
  <span class="na">max_iterations</span><span class="pi">:</span> <span class="m">20</span>
  <span class="na">max_concurrent_children</span><span class="pi">:</span> <span class="m">1</span>
  <span class="na">max_spawn_depth</span><span class="pi">:</span> <span class="m">1</span>
  <span class="na">orchestrator_enabled</span><span class="pi">:</span> <span class="kc">false</span>
  <span class="na">subagent_auto_approve</span><span class="pi">:</span> <span class="kc">false</span>
  <span class="na">default_toolsets</span><span class="pi">:</span> <span class="s1">'</span><span class="s">["file",</span><span class="nv"> </span><span class="s">"terminal"]'</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The local Gemma workflow test showed that it can classify common engineering tasks:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre>bug fixing             → bug fixing
missing test suite     → test writing
README update          → documentation
duplicate code cleanup → refactoring
API vulnerability scan → security review
new CSV export         → feature implementation
vague cleanup request  → clarification
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Measured result from seven prompts:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>Workflow routing:       7/7 correct
Response structure:     7/7 compliant
Detailed plan checks:   6/7 passed
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The one detailed miss was in the security-review case: Gemma selected the correct workflow and asked for the missing API code, but did not explicitly include remediation in its plan. That is a useful limitation to know. Correct routing does not imply complete execution planning.</p>

<p>The raw test script and result are kept with the Hermes setup:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>&lt;hermes-config-dir&gt;/scripts/test_local_gemma_workflows.py
&lt;hermes-config-dir&gt;/docs/LOCAL-GEMMA-WORKFLOW-TEST.md
&lt;hermes-config-dir&gt;/test-results/local_gemma_workflow_results.json
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Run it again with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre><span class="nb">cd</span> &lt;hermes-config-dir&gt;
python3 scripts/test_local_gemma_workflows.py
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="why-automatic-smart-routing-is-not-enabled-here">Why automatic smart routing is not enabled here</h2>

<p>The config contains a placeholder:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre><span class="na">smart_model_routing</span><span class="pi">:</span>
  <span class="na">enabled</span><span class="pi">:</span> <span class="kc">false</span>
  <span class="na">max_simple_chars</span><span class="pi">:</span> <span class="m">160</span>
  <span class="na">max_simple_words</span><span class="pi">:</span> <span class="m">28</span>
  <span class="na">cheap_model</span><span class="pi">:</span> <span class="pi">{}</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>In this installed version, the option is written by setup but there is no active runtime implementation that reliably classifies every incoming task and switches the main model. Turning the flag on would create false confidence.</p>

<p>Until that runtime support exists, these are the practical routing policies:</p>

<h3 id="policy-a-local-by-default-for-maintenance">Policy A: local by default for maintenance</h3>

<p>Use <code class="language-plaintext highlighter-rouge">local-gemma</code> for:</p>

<ul>
  <li>dependency inspection;</li>
  <li>small bundle or Yarn updates;</li>
  <li>README and documentation edits;</li>
  <li>test scaffolding;</li>
  <li>simple refactors;</li>
  <li>repository status and diagnostics;</li>
  <li>preparing a plan for human approval.</li>
</ul>

<h3 id="policy-b-cloud-for-difficult-reasoning">Policy B: cloud for difficult reasoning</h3>

<p>Use the cloud model when the task involves:</p>

<ul>
  <li>a large unfamiliar codebase;</li>
  <li>subtle production debugging;</li>
  <li>security-sensitive changes;</li>
  <li>difficult migrations;</li>
  <li>architecture decisions with broad consequences;</li>
  <li>interpreting many failures across services;</li>
  <li>work where the local model repeatedly gets stuck.</li>
</ul>

<h3 id="policy-c-hybrid-session">Policy C: hybrid session</h3>

<p>Start locally. If Gemma cannot progress, stop rather than letting it improvise. Capture the current state and restart with a cloud model using the local output as evidence:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre>hermes chat <span class="se">\</span>
  <span class="nt">--model</span> local-gemma <span class="se">\</span>
  <span class="nt">--in</span> <span class="s2">"/path/to/my-rails-app"</span> <span class="se">\</span>
  <span class="nt">--toolsets</span> file,terminal <span class="se">\</span>
  <span class="nt">--reasoning</span> low
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Then, only if needed:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre>hermes chat <span class="se">\</span>
  <span class="nt">--in</span> <span class="s2">"/path/to/my-rails-app"</span> <span class="se">\</span>
  <span class="nt">--toolsets</span> file,terminal <span class="se">\</span>
  <span class="nt">--reasoning</span> medium <span class="se">\</span>
  <span class="nt">-q</span> <span class="s1">'Continue the dependency-maintenance investigation. Use the prior inspection notes below. Do not repeat completed commands. Identify the smallest safe next step and explain why it needs stronger reasoning. [paste notes]'</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="the-orchestration-checklist">The orchestration checklist</h2>

<p>Before starting:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre><span class="nb">cd</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/public_html/my-rails-app"</span>
git status <span class="nt">--short</span> <span class="nt">--branch</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Ask the agent to establish:</p>

<ul>
  <li>repository root;</li>
  <li>current branch and uncommitted changes;</li>
  <li>Ruby and Node versions;</li>
  <li>Bundler and JavaScript package manager;</li>
  <li>test, lint, and build commands;</li>
  <li>whether the working tree is safe to modify;</li>
  <li>the exact requested scope.</li>
</ul>

<p>Before mutating:</p>

<ul>
  <li>require a written plan;</li>
  <li>prefer named dependency updates over broad updates;</li>
  <li>show exact commands;</li>
  <li>confirm the lockfile and source files that may change;</li>
  <li>ensure no deployment or push is included;</li>
  <li>keep a rollback path through git.</li>
</ul>

<p>After mutating:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre>git diff <span class="nt">--stat</span>
git diff <span class="nt">--</span> Gemfile Gemfile.lock package.json yarn.lock
git status <span class="nt">--short</span> <span class="nt">--branch</span>
bundle check
yarn check <span class="nt">--integrity</span> <span class="o">||</span> <span class="nb">true
</span>bundle <span class="nb">exec </span>rails <span class="nb">test</span> <span class="o">||</span> bundle <span class="nb">exec </span>rspec
yarn <span class="nb">test</span> <span class="o">||</span> <span class="nb">true
</span>yarn build <span class="o">||</span> <span class="nb">true</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Use the commands the repository actually defines. The final report should separate:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre>Changed:
Tests run:
Tests passed:
Tests failed:
Warnings:
Remaining risk:
Rollback:
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="a-reusable-orchestration-prompt">A reusable orchestration prompt</h2>

<p>This prompt works well for routine Rails maintenance:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
</pre></td><td class="rouge-code"><pre>You are operating in a Rails repository. Work as a cautious maintenance engineer.

Goal: inspect and, only after approval, perform the requested dependency update.

Rules:
1. Start with read-only inspection.
2. Identify the repository root and package managers from files, not assumptions.
3. Check git status before touching anything.
4. Report Ruby, Rails, Bundler, Node, Yarn/npm/pnpm versions when available.
5. Propose the smallest command that satisfies the request.
6. Do not run bundle update, yarn upgrade, migrations, deployment, commit, push, deletion, or commands outside the repository without explicit approval.
7. Before a mutating command, show the exact command and explain its scope.
8. After changes, inspect the diff and run the project's own tests, lint, and build checks.
9. Never claim a test passed unless you actually ran it.
10. If the request is ambiguous or the repository state is unsafe, stop and ask for clarification.

Requested task: inspect the app and prepare a plan for a Bundler and Yarn upgrade.
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="the-deeper-lesson">The deeper lesson</h2>

<p>The model is only one part of the system. Reliable orchestration comes from combining:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre>model choice
+ workspace boundary
+ tool access
+ approval policy
+ iterative tool loop
+ verification
+ rollback
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Local Gemma is attractive because it removes cost and network dependency from routine work. But the largest reliability gains do not come from model size alone. They come from giving the model a narrow task, a real workspace, explicit constraints, and a requirement to verify its own changes.</p>

<p>For my current setup, the practical rule is simple:</p>

<blockquote>
  <p><strong>Start routine engineering work with local Gemma, keep the scope narrow, approve mutations, verify everything, and escalate to a cloud model only when the local path demonstrates that it needs help.</strong></p>
</blockquote>

<p>This is not fully automatic orchestration yet. It is deliberate orchestration, and it is already useful.</p>]]></content><author><name>Joey Wang</name></author><category term="ai" /><category term="engineering" /><category term="hermes" /><category term="agents" /><category term="gemma" /><category term="local-ai" /><category term="coding-agent" /><category term="rails" /><category term="orchestration" /><summary type="html"><![CDATA[A practical guide to using Hermes Agent with a local Gemma model for routine coding work, while reserving cloud models for tasks that genuinely need them.]]></summary></entry><entry><title type="html">Agent 基建的三层爆发：电脑、运行时、技能方法论</title><link href="https://joeywang.github.io/posts/agent-infrastructure-three-layer-surge/" rel="alternate" type="text/html" title="Agent 基建的三层爆发：电脑、运行时、技能方法论" /><published>2026-08-10T08:12:00+00:00</published><updated>2026-08-10T08:12:00+00:00</updated><id>https://joeywang.github.io/posts/agent-infrastructure-three-layer-surge</id><content type="html" xml:base="https://joeywang.github.io/posts/agent-infrastructure-three-layer-surge/"><![CDATA[<h1 id="agent-基建的三层爆发电脑运行时技能方法论">Agent 基建的三层爆发：电脑、运行时、技能方法论</h1>

<p>这几天 GitHub 上一批 Agent 相关项目突然变得很热。表面上看，它们都在讲 “AI coding agent” 或 “agent skills”。但放在一起看，我觉得更准确的判断是：</p>

<p><strong>Agent 正在从聊天框，变成一种有工作台、有运行时、有工程方法论的执行系统。</strong></p>

<p>这不是又一轮 prompt trick 的热闹。真正值得注意的是基础设施在补齐。</p>

<p>以前我们问模型一个问题，模型回答。后来我们给它工具，让它能读文件、跑命令、查网页。再后来我们给它 memory，让它不必每次从零开始。</p>

<p>现在的问题变成了：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>一个 Agent 要长期可靠地干活，周围到底需要什么？
</pre></td></tr></tbody></table></code></pre></div></div>

<p>这五个项目刚好可以拆成三层。</p>

<h2 id="第一层给-agent-一台电脑">第一层：给 Agent 一台电脑</h2>

<p>代表项目是 <a href="https://github.com/cloudflare/computer">cloudflare/computer</a>。它的描述很直接：Give your agent a computer。</p>

<p>这里的 “computer” 不是一台完整虚拟机的营销说法，而是一个 Agent 需要的最小工作台：</p>

<ul>
  <li>一个可以持久读写的文件系统；</li>
  <li>一个统一的执行接口；</li>
  <li>多种可插拔的执行后端；</li>
  <li>可以围绕 agent session 保存和恢复的工作状态。</li>
</ul>

<p>它的文档里提到 SQLite-backed virtual filesystem，也提到通过 <code class="language-plaintext highlighter-rouge">workspace.runtime</code> 选择不同执行后端。这个抽象很重要，因为 Agent 如果只有聊天记录，就没有真正的工作现场。</p>

<p>一个严肃的 Agent 不应该只会说：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>我会创建一个文件。
</pre></td></tr></tbody></table></code></pre></div></div>

<p>它应该真的有地方创建文件、修改文件、运行检查、留下结果：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre>workspace
  -&gt; files
  -&gt; commands
  -&gt; outputs
  -&gt; receipts
</pre></td></tr></tbody></table></code></pre></div></div>

<p>这也是我越来越觉得 Agent 系统里 “workspace” 应该成为一等对象的原因。聊天记录是交互界面，不是完整工作台。</p>

<p>对本地优先的系统来说，这个 workspace 未必需要在 Cloudflare 上。它可以是本地目录、git worktree、容器、数据库、QMD collection，或者这些东西的组合。关键不是技术选型，而是这个概念本身：<strong>Agent 需要一个可恢复、可审计、可清理的工作现场。</strong></p>

<h2 id="第二层长任务需要运行时不只是更长上下文">第二层：长任务需要运行时，不只是更长上下文</h2>

<p>代表项目是 <a href="https://github.com/PrimeIntellect-ai/prime-agent">PrimeIntellect-ai/prime-agent</a>。它把自己描述成 self-improving RLM agent for coding workflows and long-running autonomous tasks。</p>

<p>这里最值得看的不是 “它也能写代码”。能写代码已经不是新鲜事。</p>

<p>真正值得看的是它围绕长任务做的结构：background agents、daemon、worker、kernel、persistence、heartbeats、schedules、subagents、skills。换句话说，它不是把 Agent 当成一次性对话，而是当成一个可以脱离终端、继续推进任务、之后再被重新接上的运行实体。</p>

<p>这解决的是一个很现实的问题：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>长任务不是一次回答。
长任务是一条会中断、会等待、会失败、会恢复的轨迹。
</pre></td></tr></tbody></table></code></pre></div></div>

<p>如果 Agent 只是一个聊天窗口，那么断线、超时、上下文丢失、人离开电脑，都会把工作变得脆弱。长任务需要的是运行时边界：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>goal
  -&gt; current frontier
  -&gt; background execution
  -&gt; heartbeat
  -&gt; evidence receipt
  -&gt; reattach / resume
</pre></td></tr></tbody></table></code></pre></div></div>

<p>这和普通 “把 context window 变大” 不是一回事。更长上下文可以让模型看到更多历史，但它不能自动解决：</p>

<ul>
  <li>当前任务推进到哪一步；</li>
  <li>哪一步需要人批准；</li>
  <li>上一次真实验证结果是什么；</li>
  <li>失败后应该从哪里恢复；</li>
  <li>哪些证据可以重新读取。</li>
</ul>

<p>所以我更关心的是 goal register、frontier、gate、receipt 这些词，而不是单纯的 “agent memory”。Memory 很容易变成一个什么都往里塞的垃圾场。长任务需要的其实是更结构化的状态。</p>

<h2 id="第三层技能不是提示词是工程方法论的包装">第三层：技能不是提示词，是工程方法论的包装</h2>

<p>另外三个项目都和 skills 有关，但侧重点不同。</p>

<p><a href="https://github.com/addyosmani/agent-skills">addyosmani/agent-skills</a> 更像生产级工程技能包。它不只是放一堆提示词，而是把 skills、agents、hooks、commands、references、evals 和 docs 组织在一起。它强调技能应该 specific、verifiable、battle-tested、minimal。</p>

<p><a href="https://github.com/mattpocock/skills">mattpocock/skills</a> 的气质不太一样。它像是从一个真实工程师的 <code class="language-plaintext highlighter-rouge">.agents</code> 目录里提炼出来的工作习惯：handoff、grilling、writing-for-agents、架构和交付流程。它有一种很实用的味道：少一点平台感，多一点 “我真的每天这样用”。</p>

<p><a href="https://github.com/obra/superpowers">obra/superpowers</a> 则更像一套 agentic skills framework 加软件开发方法论。它强调的不只是单个技能，而是如何用技能组织开发流程、子 Agent、计划、review 和修复循环。</p>

<p>这三个项目合在一起说明了一件事：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>Skill 不应该只是“更好的提示词”。
Skill 应该是可复用的工程行为单元。
</pre></td></tr></tbody></table></code></pre></div></div>

<p>一个好的 Agent skill 至少应该回答：</p>

<ul>
  <li>什么时候触发？</li>
  <li>先读什么上下文？</li>
  <li>做哪些步骤？</li>
  <li>哪些地方必须停下来问人？</li>
  <li>用什么证据证明完成？</li>
  <li>如果失败，怎么恢复？</li>
</ul>

<p>如果一个 skill 只是在说 “请写高质量代码”，那它几乎没有价值。真正有价值的 skill 应该把资深工程师脑内的流程压缩下来：如何澄清需求，如何写计划，如何做最小改动，如何跑验证，如何请求 review，如何交接给另一个 Agent。</p>

<h2 id="这三层为什么会同时爆发">这三层为什么会同时爆发</h2>

<p>我觉得原因很简单：大家已经意识到，单个模型能力再强，也不能单独构成可靠系统。</p>

<p>一个能干活的 Agent 至少需要这三件事：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>workspace      -&gt; 它在哪里工作
runtime        -&gt; 它如何持续工作
methodology    -&gt; 它按什么方式工作
</pre></td></tr></tbody></table></code></pre></div></div>

<p>没有 workspace，Agent 只能在对话里想象文件和结果。</p>

<p>没有 runtime，Agent 很难处理长任务、失败恢复和异步等待。</p>

<p>没有 methodology，Agent 可能会写代码，但不会像工程师一样交付。</p>

<p>所以这五个项目看起来分散，其实在补同一张图：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre>agent request
  -&gt; workspace with files/tools
  -&gt; runtime with state/recovery
  -&gt; skills with engineering discipline
  -&gt; verified artifact
</pre></td></tr></tbody></table></code></pre></div></div>

<p>这也是我觉得 “Agent 基建” 这个词比 “AI 编码工具” 更准确的原因。</p>

<h2 id="对我自己的启发">对我自己的启发</h2>

<p>我现在更愿意把 Agent 系统看成一个分层架构，而不是一个聪明模型外面包一点工具。</p>

<p>对一个本地优先、长期使用的个人 Agent 来说，比较理想的形态应该是：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre>stable preferences      -&gt; memory
rich notes/research     -&gt; QMD / docs
repeatable procedures   -&gt; skills
repeated deterministic  -&gt; scripts / commands
scheduled checks        -&gt; cron / no-agent jobs
long-horizon work       -&gt; goal register + receipts
code work               -&gt; worktree / sandbox / CI
public outputs          -&gt; reviewed blog/social pipeline
</pre></td></tr></tbody></table></code></pre></div></div>

<p>模型负责判断和综合。工具负责观察和执行。脚本负责重复。技能负责方法。文档负责长期知识。Git 负责历史。人负责边界、价值判断和授权。</p>

<p>这比 “让 Agent 自动化一切” 更保守，但也更可靠。</p>

<h2 id="不要被星标带跑">不要被星标带跑</h2>

<p>这类项目很容易让人兴奋。星标暴涨会制造一种 “必须马上安装” 的冲动。</p>

<p>我不太认同这个顺序。</p>

<p>对 Agent 基础设施，正确顺序应该是：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre>先读架构
再看边界
再做小实验
最后才考虑采用
</pre></td></tr></tbody></table></code></pre></div></div>

<p>尤其是能执行代码、写文件、联网、长期运行的系统，一定要先看清楚：</p>

<ul>
  <li>文件系统边界在哪里；</li>
  <li>命令执行有没有隔离；</li>
  <li>凭证如何处理；</li>
  <li>任务状态保存在哪里；</li>
  <li>telemetry 默认行为是什么；</li>
  <li>失败后如何恢复；</li>
  <li>人类审批点能不能强制执行。</li>
</ul>

<p>越像基础设施，越不能只看 demo。</p>

<h2 id="我会优先研究什么">我会优先研究什么</h2>

<p>如果只选几个方向继续看，我会这样排：</p>

<ol>
  <li><strong>Prime Agent 的长任务运行时</strong>：daemon、worker、kernel、persistence、heartbeat、resume。</li>
  <li><strong>Cloudflare Computer 的 workspace 抽象</strong>：虚拟文件系统、执行后端、agent tool interface。</li>
  <li><strong>Superpowers 的方法论</strong>：subagent-driven development、plan-scoped workspace、review/fix loop。</li>
  <li><strong>agent-skills 的技能质量标准</strong>：specific、verifiable、battle-tested、minimal。</li>
  <li><strong>mattpocock/skills 的实战习惯</strong>：handoff、grilling、writing-for-agents。</li>
</ol>

<p>我不一定会直接采用任何一个项目。但我会把它们当成一组很好的设计样本。</p>

<p>因为真正的问题不是 “哪个 Agent 框架会赢”。真正的问题是：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>一个长期和人一起工作的 Agent，
需要哪些基础设施才能可靠地完成真实工作？
</pre></td></tr></tbody></table></code></pre></div></div>

<p>这五个项目给出的答案越来越清楚了：</p>

<p>它需要一台电脑。<br />
它需要一个运行时。<br />
它需要一套工程方法论。</p>

<p>而模型，只是这个系统里最聪明、也最不应该单独承担全部责任的那一层。</p>

<h2 id="参考项目">参考项目</h2>

<ul>
  <li><a href="https://github.com/PrimeIntellect-ai/prime-agent">PrimeIntellect-ai/prime-agent</a></li>
  <li><a href="https://github.com/cloudflare/computer">cloudflare/computer</a></li>
  <li><a href="https://github.com/addyosmani/agent-skills">addyosmani/agent-skills</a></li>
  <li><a href="https://github.com/mattpocock/skills">mattpocock/skills</a></li>
  <li><a href="https://github.com/obra/superpowers">obra/superpowers</a></li>
</ul>]]></content><author><name>Joey Wang</name></author><category term="ai" /><category term="engineering" /><category term="ai" /><category term="agents" /><category term="engineering" /><category term="infrastructure" /><category term="hermes" /><summary type="html"><![CDATA[从 Prime Agent、Cloudflare Computer、agent-skills、mattpocock/skills 和 Superpowers 看 Agent 基础设施正在如何从聊天框走向持久工作台、长任务运行时和工程方法论。]]></summary></entry><entry><title type="html">Tightening a dev VM that was quietly living without a firewall</title><link href="https://joeywang.github.io/posts/vm-security-tightening/" rel="alternate" type="text/html" title="Tightening a dev VM that was quietly living without a firewall" /><published>2026-08-10T07:15:00+00:00</published><updated>2026-08-10T07:15:00+00:00</updated><id>https://joeywang.github.io/posts/vm-security-tightening</id><content type="html" xml:base="https://joeywang.github.io/posts/vm-security-tightening/"><![CDATA[<h1 id="tightening-a-dev-vm-that-was-quietly-living-without-a-firewall">Tightening a dev VM that was quietly living without a firewall</h1>

<p>I spent an afternoon auditing a small always-on VM that runs my personal AI agent, a couple of VPNs, and some dev infrastructure. It started as a routine setup review. It ended with me closing a database that required no password, on a machine with a public IPv4 address.</p>

<p>The fix list was useful. The process of finding the list was more useful. Here is both.</p>

<h2 id="the-audit-that-stopped-being-hypothetical">The audit that stopped being hypothetical</h2>

<p>The box had felt fine for months. SSH was key-only. Nothing had ever been breached. The agent answered messages, the VPNs worked, scheduled jobs ran. Nothing felt broken, so nothing got looked at.</p>

<p>Then I actually looked.</p>

<p>Three quick probes changed the mood:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre><span class="c"># what is listening, and on which interfaces?</span>
ss <span class="nt">-tlnp</span>

<span class="c"># can I connect to the dev postgres without a password?</span>
psql <span class="nt">-h</span> &lt;host&gt; <span class="nt">-p</span> &lt;nonstandard_port&gt; <span class="nt">-U</span> postgres <span class="nt">-c</span> <span class="s2">"select 1"</span>

<span class="c"># does redis ask for a password?</span>
redis-cli <span class="nt">-p</span> &lt;nonstandard_port&gt; config get requirepass
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The answers were: a lot, yes, and no.</p>

<p>Two containers — a dev Postgres and a dev Redis — were published on all interfaces with <code class="language-plaintext highlighter-rouge">trust</code> auth and an empty password. The Postgres prompt sat there and printed a row when I asked nicely. The Redis had no <code class="language-plaintext highlighter-rouge">requirepass</code> at all. Both were reachable from any network path the host was on, and the host has a public IP.</p>

<p>Redis with no auth is not a sleepy little cache. It is a write target: SSH key planting, cron overwrites, ransomware. The fact that it held zero keys was luck, not design.</p>

<h2 id="why-it-happened">Why it happened</h2>

<p>The usual reasons, in the usual order:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">docker compose</code> publishes ports to <code class="language-plaintext highlighter-rouge">0.0.0.0</code> unless you say otherwise.</li>
  <li>The Postgres container was configured with <code class="language-plaintext highlighter-rouge">POSTGRES_HOST_AUTH_METHOD: trust</code> because it was “just dev”.</li>
  <li>There was no host firewall. The cloud security group was the only barrier, and I could not see its rules from inside the box.</li>
  <li>Nothing audited any of this because nothing had failed.</li>
</ul>

<p>None of those are exotic mistakes. That is the point. A dev database with no password is one of the most common setups in the industry, and on a machine with a public address it is a live finding, not a vibe.</p>

<h2 id="the-checklist">The checklist</h2>

<p>I ran these before touching anything:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>ss <span class="nt">-tlnp</span>                 <span class="c"># listeners and bind addresses</span>
ufw status               <span class="c"># is there a host firewall?</span>
systemctl <span class="nt">--failed</span>       <span class="c"># units that died and stayed dead</span>
systemctl list-units     <span class="c"># what is actually running</span>
docker ps                <span class="c"># container ports</span>
<span class="nb">ls</span> <span class="nt">-la</span> &lt;agent_config&gt;/scripts <span class="c"># script permissions (group-writable is a smell)</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Plus an agent-side security check: which automation jobs run with full tool access, and which command classes are auto-approved without prompting.</p>

<p>The port scan, the failed units, the auth probes, and the permission check each produced at least one finding. Nothing was a single smoking gun; everything was small and accumulated.</p>

<h2 id="fix-1-stop-publishing-to-the-world">Fix 1: stop publishing to the world</h2>

<p>The smallest change with the largest effect: bind the published ports to loopback.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre><span class="na">services</span><span class="pi">:</span>
  <span class="na">db</span><span class="pi">:</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">127.0.0.1:&lt;db_port&gt;:5432"</span>   <span class="c1"># was "&lt;db_port&gt;:5432"</span>
  <span class="na">redis</span><span class="pi">:</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">127.0.0.1:&lt;redis_port&gt;:6379"</span>   <span class="c1"># was "&lt;redis_port&gt;:6379"</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Local tooling still works. Nothing outside the host can connect. Verify with <code class="language-plaintext highlighter-rouge">ss</code> and confirm there is no <code class="language-plaintext highlighter-rouge">0.0.0.0</code> line left.</p>

<h2 id="fix-2-auth-that-is-actually-enforced">Fix 2: auth that is actually enforced</h2>

<p>Rebinding fixes reachability. It does not fix the fact that the database had no password, so I set one and switched the auth method to SCRAM.</p>

<p>The gotcha: the official Postgres image only applies <code class="language-plaintext highlighter-rouge">POSTGRES_HOST_AUTH_METHOD</code> and <code class="language-plaintext highlighter-rouge">POSTGRES_PASSWORD</code> when it initializes a <em>new</em> data directory. My volume already existed, so the container recreated happily with <code class="language-plaintext highlighter-rouge">trust</code> still in <code class="language-plaintext highlighter-rouge">pg_hba.conf</code>. Config said one thing; enforcement said another.</p>

<p>For an existing volume you have to do it by hand:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
</pre></td><td class="rouge-code"><pre><span class="c"># set the password</span>
docker <span class="nb">exec</span> &lt;db-container&gt; psql <span class="nt">-U</span> postgres <span class="nt">-c</span> <span class="se">\</span>
  <span class="s2">"ALTER USER postgres PASSWORD '&lt;generated&gt;';"</span>

<span class="c"># rewrite pg_hba.conf: only the host lines, local socket admin stays</span>
docker <span class="nb">exec</span> &lt;db-container&gt; <span class="nb">sed</span> <span class="nt">-i</span> <span class="se">\</span>
  <span class="s1">'s/^\(host.*\)trust$/\1scram-sha-256/'</span> <span class="se">\</span>
  /var/lib/postgresql/data/pg_hba.conf

<span class="c"># reload</span>
docker <span class="nb">exec</span> &lt;db-container&gt; psql <span class="nt">-U</span> postgres <span class="nt">-c</span> <span class="s2">"SELECT pg_reload_conf();"</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Then prove it both ways:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre><span class="nv">PGPASSWORD</span><span class="o">=</span><span class="s1">''</span> psql <span class="nt">-h</span> 127.0.0.1 <span class="nt">-p</span> &lt;db_port&gt; <span class="nt">-U</span> postgres <span class="nt">-c</span> <span class="s2">"select 1"</span>   <span class="c"># must fail</span>
<span class="nv">PGPASSWORD</span><span class="o">=</span><span class="s1">'&lt;generated&gt;'</span> psql <span class="nt">-h</span> 127.0.0.1 <span class="nt">-p</span> &lt;db_port&gt; <span class="nt">-U</span> postgres <span class="nt">-c</span> <span class="s2">"select 1"</span>  <span class="c"># works</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Redis is the same story in one line:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre><span class="nb">command</span>: <span class="o">[</span><span class="s2">"redis-server"</span>, <span class="s2">"--requirepass"</span>, <span class="s2">"</span><span class="k">${</span><span class="nv">REDIS_PASSWORD</span><span class="k">}</span><span class="s2">"</span><span class="o">]</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="fix-3-a-host-firewall-careful-about-the-vpn">Fix 3: a host firewall, careful about the VPN</h2>

<p>The cloud security group is not a firewall I can see. I wanted defense in depth that I <em>can</em> see, so I added targeted drops on the host: inbound SMTP, rpcbind, and NFS ports that no service actually needs.</p>

<p>Two rules for myself:</p>

<ol>
  <li><strong>Never touch the FORWARD chain.</strong> VPN client traffic flows through it; messing with it risks breaking connectivity for everyone who routes through this box. INPUT drops only affect connections to the host itself.</li>
  <li><strong>Keep SSH and the VPN ports open, always.</strong></li>
</ol>

<p>I applied the drops with an idempotent script (delete existing instances, then apply) behind a small systemd oneshot unit so it survives reboots:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="c"># /usr/local/sbin/fw-hardening.sh — the delete-then-apply pattern</span>
iptables <span class="nt">-D</span> INPUT <span class="nt">-p</span> tcp <span class="nt">--dport</span> 25 <span class="nt">-j</span> DROP 2&gt;/dev/null
iptables <span class="nt">-A</span> INPUT <span class="nt">-p</span> tcp <span class="nt">--dport</span> 25 <span class="nt">-j</span> DROP
</pre></td></tr></tbody></table></code></pre></div></div>

<p>I learned why idempotency matters the hard way: the first version checked-then-inserted, and when the unit ran a second time at <code class="language-plaintext highlighter-rouge">enable --now</code>, I ended up with duplicate rules. The delete-then-apply form makes re-running safe, and rollback is just the delete lines.</p>

<h2 id="fix-4-small-hygiene">Fix 4: small hygiene</h2>

<ul>
  <li>SSH: explicit <code class="language-plaintext highlighter-rouge">PermitRootLogin no</code> drop-in instead of relying on the default.</li>
  <li>Two dead services that failed at boot and stayed enabled: disabled and masked.</li>
  <li>Group-writable scripts in the automation directory: <code class="language-plaintext highlighter-rouge">chmod 755</code>.</li>
</ul>

<p>None of these are exciting. They are the difference between a host that was accidentally secure and one that is deliberately boring.</p>

<h2 id="fix-5-least-privilege-for-the-automation">Fix 5: least privilege for the automation</h2>

<p>The agent-side audit found the same disease I had just cured in the containers: things running with more power than they need.</p>

<ul>
  <li>Six scheduled jobs ran with full tool access. Each job now gets a minimal toolset: a briefing job gets file access and the shell; a research job additionally gets web access; nothing gets tools it does not use.</li>
  <li>The auto-approval list included broad command classes: recursive delete, inline shell execution. Those now require explicit approval every time.</li>
</ul>

<p>I kept one sandboxed script-execution tool auto-approved, deliberately, because it is core to how the agent works day to day. That is a tradeoff, and it should be a decision, not a default. Everything else went behind an approval prompt.</p>

<h2 id="the-memory-lesson-on-the-side">The memory lesson, on the side</h2>

<p>The same review caught a cost problem that is adjacent to security: a local language model server was running all the time, holding about 6.6 GB of RSS on a 12 GB host, as the last-resort fallback for the agent. It is used for bounded tasks a few times a week.</p>

<p>It is now on-demand: stopped and disabled by default, started for a task, stopped when the task ends. Service states are now explicit:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>must-run    -&gt; SSH, VPN, the agent control plane, the firewall unit
on-demand   -&gt; local model server, databases, container workloads
retired     -&gt; whatever is backed up and documented, then removed
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The on-demand model server also makes the fallback chain honest: it is the last entry, reached only if the primary and every cloud fallback fail, and if it is not running it fails fast instead of pretending to be a safety net.</p>

<h2 id="lessons">Lessons</h2>

<ul>
  <li><strong>A dev server is a production server to anyone on the internet.</strong> “It is just dev” is a story you tell yourself; the port scanner does not care.</li>
  <li><strong>Localhost binding is not automatic.</strong> Compose publishes to all interfaces unless you say <code class="language-plaintext highlighter-rouge">127.0.0.1:</code>.</li>
  <li><strong>Verify enforcement, not config.</strong> The Postgres container “had” a password env var; the database still accepted empty credentials. The test is <code class="language-plaintext highlighter-rouge">passwordless login must fail</code>, not “the YAML looks right”.</li>
  <li><strong>A cloud security group is not a firewall you can see.</strong> Add one you can see, and write down the rollback.</li>
  <li><strong>Least privilege applies to automation too.</strong> Scheduled jobs and agent tools need the same scrutiny as container ports.</li>
  <li><strong>Documentation near the system beats memory.</strong> Every change in that session got a note next to the thing it changed, including the one-liner to undo it.</li>
</ul>

<p>None of this was clever. It was a checklist, applied honestly, with tests that prove the fix actually worked. That is the entire trick.</p>]]></content><author><name>Joey Wang</name></author><category term="engineering" /><category term="devops" /><category term="devops" /><category term="security" /><category term="self-hosting" /><category term="linux" /><category term="containers" /><category term="iptables" /><category term="automation" /><summary type="html"><![CDATA[What a routine security audit found on a small always-on dev VM — unauthenticated databases, no host firewall, over-privileged automation — and the fixes that made it boring again.]]></summary></entry><entry><title type="html">Golden Rules for Cheaper, Safer LLM Agents</title><link href="https://joeywang.github.io/posts/golden-rules-for-cheaper-safer-llm-agents/" rel="alternate" type="text/html" title="Golden Rules for Cheaper, Safer LLM Agents" /><published>2026-08-09T19:15:00+00:00</published><updated>2026-08-09T19:15:00+00:00</updated><id>https://joeywang.github.io/posts/golden-rules-for-cheaper-safer-llm-agents</id><content type="html" xml:base="https://joeywang.github.io/posts/golden-rules-for-cheaper-safer-llm-agents/"><![CDATA[<h1 id="golden-rules-for-cheaper-safer-llm-agents">Golden rules for cheaper, safer LLM agents</h1>

<p><em>Part of the <a href="/ai-agents/">AI &amp; Agents reading path</a>.</em></p>

<p>A small thing happened while I was wiring a few Hermes hosts together.</p>

<p>I had a workflow that I could have left as a chat instruction: collect a little status from two worker machines, check that the shared knowledge repo does not contain secrets, commit the update, push it, sync the repo back to the workers, and verify that they received it.</p>

<p>That is exactly the kind of thing an LLM agent can do interactively.</p>

<p>But it is also exactly the kind of thing an LLM agent should not keep doing interactively forever.</p>

<p>The first run needs judgment. What should be shared? What should stay private? What is the source of truth? Which machines are allowed to decide, and which should only report evidence?</p>

<p>After that, the shape is mostly deterministic.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>collect status
  -&gt; scan for secrets
  -&gt; commit
  -&gt; push
  -&gt; rsync
  -&gt; verify
</pre></td></tr></tbody></table></code></pre></div></div>

<p>So the workflow became a Python script. Then the script became a no-agent cron job.</p>

<p>That little promotion is one of the most important patterns I have learned from using LLM agents seriously: the LLM is not the whole system. The system is the loop around it, and the loop should get cheaper and safer every time it repeats.</p>

<h2 id="use-the-cheapest-layer-that-can-do-the-job">Use the cheapest layer that can do the job</h2>

<p>The simplest rule is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>LLM for judgment.
Scripts for repetition.
</pre></td></tr></tbody></table></code></pre></div></div>

<p>A model is useful when the work is ambiguous:</p>

<ul>
  <li>What matters here?</li>
  <li>What is risky?</li>
  <li>Which failure is probably root cause?</li>
  <li>Should this become memory, a skill, a script, or a scheduled job?</li>
  <li>Is this public article leaking too much operational detail?</li>
</ul>

<p>But a model is wasteful when the work is deterministic:</p>

<ul>
  <li>count files;</li>
  <li>parse JSON;</li>
  <li>check disk space;</li>
  <li>list pending posts from an API;</li>
  <li>run the same health check every Monday;</li>
  <li>compare a known status output against a known policy.</li>
</ul>

<p>That work belongs to tools, scripts, SQL, shell commands, APIs, or cron. The model can interpret the result if needed. It should not be paid to rediscover the mechanics every time.</p>

<p>A useful stack looks more like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre>judgment / ambiguity      -&gt; LLM
repeated procedure        -&gt; workflow doc or skill
deterministic action      -&gt; Python script
fast manual handle        -&gt; slash command
scheduled deterministic   -&gt; no-agent cron
rich searchable knowledge -&gt; notes / docs / QMD
stable preference         -&gt; memory
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The hard part is not knowing these layers exist. The hard part is noticing when a task has changed layers.</p>

<h2 id="promote-repeated-prompts-downward">Promote repeated prompts downward</h2>

<p>Natural language is a very good starting point. It lets me say:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>Review this workflow and turn it into something reusable.
</pre></td></tr></tbody></table></code></pre></div></div>

<p>I do not need to know the exact files or all the edge cases yet. The agent can inspect, ask questions, propose a shape, and do the first implementation.</p>

<p>But if I ask the same thing again and again, the prompt is trying to tell me something. It wants to move down the stack.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>chat prompt
  -&gt; documented workflow
  -&gt; skill
  -&gt; script
  -&gt; command
  -&gt; cron / webhook
</pre></td></tr></tbody></table></code></pre></div></div>

<p>That does not mean everything should become fully autonomous. Some workflows should stop at a checklist. Some should become a skill. Some should become a dry-run script. Some should become cron only after they are boring.</p>

<p>The point is to stop paying tokens for rediscovery.</p>

<p>If I have to explain the same constraints every week, the system has not learned. It has only remembered long enough to finish the current chat.</p>

<h2 id="verification-is-part-of-the-work">Verification is part of the work</h2>

<p>A weak agent loop ends with:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>I changed it.
</pre></td></tr></tbody></table></code></pre></div></div>

<p>A stronger loop ends with:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>I changed it, ran the verification, and this is what happened.
</pre></td></tr></tbody></table></code></pre></div></div>

<p>For code, that means tests or builds. For infrastructure, it means service state and connectivity checks. For publishing, it means the page is actually live. For scheduled posts, it means the queue contains the expected text and time.</p>

<p>The loop should be explicit:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>understand
  -&gt; gather context
  -&gt; act
  -&gt; verify with real output
  -&gt; report
  -&gt; promote the durable lesson
</pre></td></tr></tbody></table></code></pre></div></div>

<p>This changes the feel of working with an agent. The important output is not the confident explanation. The important output is the verified state change.</p>

<h2 id="memory-is-not-a-dumping-ground">Memory is not a dumping ground</h2>

<p>One easy mistake is to treat memory as the place where everything should go.</p>

<p>That creates a different problem. If everything is memory, memory stops being useful. It becomes a noisy, always-on prompt tax.</p>

<p>I prefer to separate the types of knowledge:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>memory       -&gt; stable facts and preferences needed every turn
skills       -&gt; reusable procedures
notes/docs   -&gt; rich context and longer reasoning
scripts      -&gt; deterministic actions
Git repos    -&gt; versioned operational history
session logs -&gt; temporary task history
</pre></td></tr></tbody></table></code></pre></div></div>

<p>A stable preference belongs in memory. A procedure belongs in a skill. A long explanation belongs in a document. A repeated action belongs in a script. A change history belongs in Git.</p>

<p>That separation matters because each layer has a cost. Memory is paid every turn. A document is retrieved when needed. A script runs without tokens. A Git commit gives history without forcing every future prompt to carry the whole story.</p>

<p>Good agent design is partly information budgeting.</p>

<h2 id="batch-expensive-work">Batch expensive work</h2>

<p>Some work should not happen at the moment I have the idea.</p>

<p>A voice note can be captured quickly now and processed later. A knowledge base can be embedded on a schedule. A weekly review can cluster notes in a batch. A code intelligence index can run overnight or weekly instead of during every tiny question.</p>

<p>The pattern is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>capture quickly now
process deeply later
</pre></td></tr></tbody></table></code></pre></div></div>

<p>This is not only about saving money. It also improves attention. Not every thought deserves an immediate deep workflow. Some thoughts should be captured cheaply and allowed to meet other related thoughts later.</p>

<p>For LLM systems, batching is a kind of patience.</p>

<h2 id="primary-decides-workers-report-evidence">Primary decides; workers report evidence</h2>

<p>When there are multiple agents or multiple hosts, it is tempting to make them all equally smart and equally authoritative.</p>

<p>That sounds powerful, but it can create three conflicting memories, three sets of assumptions, and three bots trying to be the final decision-maker.</p>

<p>A cleaner pattern is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre>primary agent
  -&gt; owns memory, policy, promotion, final synthesis

worker agents
  -&gt; run local checks
  -&gt; collect evidence
  -&gt; report concise results
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Workers do not need the whole brain. They need the right local tools and a clear role.</p>

<p>The primary can ask a worker to check a host, run a small test, inspect a file, or verify a backup target. The worker returns evidence. The primary decides what becomes durable knowledge.</p>

<p>This is less glamorous than a swarm demo, but it is easier to trust.</p>

<h2 id="approval-gates-are-architecture">Approval gates are architecture</h2>

<p>Human approval is sometimes treated as a weakness in agent systems. I think that is backwards.</p>

<p>For some actions, the human is not just an input device. The human is the owner of responsibility.</p>

<p>These actions should have explicit gates:</p>

<ul>
  <li>production deploys;</li>
  <li>customer-impacting infrastructure;</li>
  <li>Kubernetes changes;</li>
  <li>secrets or credentials;</li>
  <li>public publishing;</li>
  <li>billing and cost changes;</li>
  <li>destructive file or database operations.</li>
</ul>

<p>The goal is not to make the human approve every shell command. The goal is to automate everything around the meaningful decision so the approval is small, informed, and real.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>agent prepares
agent verifies preconditions
agent explains risk
human approves the boundary crossing
agent executes
agent verifies outcome
</pre></td></tr></tbody></table></code></pre></div></div>

<p>That is not slower than unsafe automation. It is faster than cleaning up after a confident mistake.</p>

<h2 id="sanitize-before-sharing">Sanitize before sharing</h2>

<p>Agents are very good at moving information between places.</p>

<p>That is useful and dangerous.</p>

<p>A private note can become a public blog post. A shell output can become a status report. A host runbook can become a shared repo. A social post can quote a detail that should have stayed internal.</p>

<p>So anything that leaves its original boundary should be scanned or reviewed for:</p>

<ul>
  <li>tokens and keys;</li>
  <li>private hostnames and IPs;</li>
  <li>customer identifiers;</li>
  <li>personal email addresses;</li>
  <li>exact internal service names when they are not necessary;</li>
  <li>raw logs with sensitive content.</li>
</ul>

<p>The standard should be boring: sanitize by default, especially when promoting internal operational work into public writing.</p>

<h2 id="make-automation-reversible">Make automation reversible</h2>

<p>A script that can only execute is usually not ready to be trusted.</p>

<p>Useful automation should have escape hatches:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre>--dry-run
--no-push
--no-rsync
--skip-remote
--execute only after review
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Dry-run mode is not just for safety. It makes the script easier to reason about. It shows what the automation believes it is about to do.</p>

<p>That is exactly the kind of surface an LLM can review well: not a vague intention, but a concrete planned action.</p>

<h2 id="turn-repeated-failures-into-guardrails">Turn repeated failures into guardrails</h2>

<p>If a failure happens once, fix it.</p>

<p>If it happens twice, encode the lesson.</p>

<p>That might mean:</p>

<ul>
  <li>add a preflight check;</li>
  <li>patch a skill;</li>
  <li>update a runbook;</li>
  <li>make a script refuse unsafe input;</li>
  <li>add a secret scan;</li>
  <li>add a verification command;</li>
  <li>schedule a deterministic watchdog.</li>
</ul>

<p>The best agent systems do not become smarter only because the model improves. They become smarter because the environment accumulates guardrails.</p>

<p>A repeated mistake should not become a heroic debugging story every time. It should become harder to repeat.</p>

<h2 id="the-real-interface-is-not-the-chat-box">The real interface is not the chat box</h2>

<p>The chat box is where the interaction starts, but it is not the whole interface.</p>

<p>The real interface is a living system:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre>human intention
  -&gt; LLM judgment
  -&gt; tools and scripts
  -&gt; versioned knowledge
  -&gt; scheduled checks
  -&gt; verification
  -&gt; human approval where needed
  -&gt; improved procedures
</pre></td></tr></tbody></table></code></pre></div></div>

<p>That is why I think the interesting question is no longer only “how do I prompt this model?”</p>

<p>The better question is:</p>

<blockquote>
  <p>How should this work evolve after the first successful run?</p>
</blockquote>

<p>If the answer stays as a prompt forever, it will keep costing attention and tokens.</p>

<p>If the answer becomes a skill, a script, a cron job, a guardrail, or a documented decision, the system has actually learned.</p>

<p>That is the practical promise of LLM agents for me: not replacing judgment, but turning repeated judgment into better tools around future judgment.</p>

<p>The model is powerful.</p>

<p>But the compounding value is in the workflow you leave behind.</p>]]></content><author><name>Joey Wang</name></author><category term="ai" /><category term="engineering" /><category term="ai" /><category term="llm" /><category term="agents" /><category term="automation" /><category term="hermes" /><category term="workflows" /><summary type="html"><![CDATA[Notes on turning repeated AI workflows into scripts, skills, cron jobs, and guardrails so agents become cheaper, safer, and more useful over time.]]></summary></entry><entry><title type="html">Loop Engineering, Graph Engineering, and the Nature of Working with LLMs</title><link href="https://joeywang.github.io/posts/loop-engineering-graph-engineering-and-llm-interaction/" rel="alternate" type="text/html" title="Loop Engineering, Graph Engineering, and the Nature of Working with LLMs" /><published>2026-08-09T17:37:00+00:00</published><updated>2026-08-09T17:37:00+00:00</updated><id>https://joeywang.github.io/posts/loop-engineering-graph-engineering-and-llm-interaction</id><content type="html" xml:base="https://joeywang.github.io/posts/loop-engineering-graph-engineering-and-llm-interaction/"><![CDATA[<h1 id="loop-engineering-graph-engineering-and-the-nature-of-working-with-llms">Loop engineering, graph engineering, and the nature of working with LLMs</h1>

<p>When people first meet an LLM, the interaction looks simple.</p>

<p>You type something. The model replies.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>human -&gt; prompt -&gt; model -&gt; answer
</pre></td></tr></tbody></table></code></pre></div></div>

<p>That picture is not wrong, but it is too small. It describes the chat box, not the work.</p>

<p>The more I use LLMs inside daily engineering and life systems, the less I think the interesting part is the prompt by itself. The interesting part is the structure around the model.</p>

<p>We started with prompts. Then context. Then agents. Then memory. Then tools. Now I keep coming back to two bigger shapes: <strong>loops</strong> and <strong>graphs</strong>.</p>

<p>Loop engineering is about how an AI system keeps doing useful work.</p>

<p>Graph engineering is about how an AI system understands relationships.</p>

<p>Both matter. They are not the same thing.</p>

<h2 id="prompt-engineering-was-the-first-layer">Prompt engineering was the first layer</h2>

<p>Prompt engineering was the first vocabulary most of us learned.</p>

<p>Ask clearly. Give examples. Specify the format. Tell the model what role to take. Add constraints. Say what good output looks like.</p>

<p>That still matters. A vague request gets vague work. A precise request gives the model more handles.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>Bad:
  Help me with this bug.

Better:
  Read the failing test, explain the likely root cause,
  make the smallest safe change, then run the focused test.
</pre></td></tr></tbody></table></code></pre></div></div>

<p>But prompt engineering has a ceiling.</p>

<p>A prompt is a moment. It depends on what I remember to say this time. It lives inside one conversation. If I have to retype the same careful instruction every week, I have not built a system. I have built a little ritual.</p>

<p>That is fine when I am exploring. It is not enough for reliable work.</p>

<h2 id="context-engineering-made-the-prompt-less-lonely">Context engineering made the prompt less lonely</h2>

<p>The next layer is context engineering.</p>

<p>The question becomes: what should the model know before it answers?</p>

<p>That might be:</p>

<ul>
  <li>the current file;</li>
  <li>the failing test output;</li>
  <li>the relevant pull request;</li>
  <li>personal preferences;</li>
  <li>old notes;</li>
  <li>documentation;</li>
  <li>a previous decision;</li>
  <li>the command that verifies the result.</li>
</ul>

<p>This is where notes, search, session history, RAG, and code tools enter the picture.</p>

<p>The model is not only generating from training data. It is being placed inside a local situation.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre>request
  + current files
  + logs
  + notes
  + memory
  + docs
  -&gt; better answer
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Context engineering changes the interaction from asking a clever stranger to asking a temporary teammate who has read the room.</p>

<p>But context also has a problem. Too little context makes the model guess. Too much context makes the model drown. Wrong context can be worse than no context, because it gives confidence to the wrong path.</p>

<p>So the skill is not “add more context”.</p>

<p>It is choosing the right context for the next action.</p>

<h2 id="tool-engineering-gave-the-model-hands">Tool engineering gave the model hands</h2>

<p>A plain LLM can describe a command. An agent with tools can run it.</p>

<p>That changes the interaction.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre>model says: run the test
agent does: run the test
agent reads: real output
model decides: next step
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The model does not need to pretend it knows whether the test passes. It can check. It does not need to invent the file contents. It can read the file. It does not need to guess the current branch. It can ask Git.</p>

<p>Tool engineering is partly about giving the model capabilities. More importantly, it removes the temptation to hallucinate.</p>

<p>If the system can observe the world, it should observe the world.</p>

<h2 id="memory-engineering-gave-the-system-continuity">Memory engineering gave the system continuity</h2>

<p>A single LLM conversation can be useful. A long-running assistant needs memory.</p>

<p>But memory is not “save everything”. That is another way to drown the model.</p>

<p>Different things belong in different places:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre>stable facts        -&gt; memory
reusable procedure -&gt; skill or workflow
documents/notes    -&gt; searchable knowledge base
code relationships -&gt; code graph
one-off progress   -&gt; session history
commands           -&gt; scripts or slash commands
scheduled work     -&gt; cron or webhook
</pre></td></tr></tbody></table></code></pre></div></div>

<p>This separation matters because each type of knowledge has a different half-life.</p>

<p>My preference for concise status reports is stable. A failed test output from this morning is not. A workflow for preparing a blog post may be reusable. The exact draft path is temporary. A production deploy rule may be durable. A branch name may be stale tomorrow.</p>

<p>Good memory engineering is not about making the AI remember everything.</p>

<p>It is about making it forget less stupidly.</p>

<h2 id="loop-engineering-is-the-agent-shape">Loop engineering is the agent shape</h2>

<p>Once the model can read context, use tools, and remember durable lessons, the question changes.</p>

<p>What loop should it run?</p>

<p>For me, a useful agent loop looks something like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre>capture
  -&gt; retrieve
  -&gt; plan
  -&gt; act
  -&gt; verify
  -&gt; report
  -&gt; remember/promote
</pre></td></tr></tbody></table></code></pre></div></div>

<p>This is loop engineering.</p>

<p>It is the difference between a model that answers and a system that works.</p>

<p>A good loop observes before acting. It reads the file, checks the branch, inspects the error, searches the notes, or asks for the missing decision.</p>

<p>It acts in small steps. It does not rewrite the whole system when a focused patch would do.</p>

<p>It verifies with reality: tests, builds, live URLs, logs, API responses, file checks, timestamps. Not vibes.</p>

<p>It knows when to stop. Infinite autonomy is not intelligence. Sometimes the correct next step is to report a blocker or ask for approval.</p>

<p>It promotes repeated work. If I keep asking the same thing, the loop should move from chat into a workflow, then maybe a command, script, API, cron job, or product feature.</p>

<p>This is why I think loop engineering is the default best practice for an assistant like Hermes. Hermes is not only a model endpoint. It is a tool-using, memory-backed, scheduled, multi-surface agent. The natural unit is not the prompt. It is the verified loop.</p>

<h2 id="graph-engineering-is-the-relationship-shape">Graph engineering is the relationship shape</h2>

<p>Graph engineering answers a different question.</p>

<p>What is connected to what?</p>

<p>A graph can represent code symbols, documents, people, decisions, projects, dependencies, concepts, tasks, APIs, or memories.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>concept: loop engineering
  -&gt; related to: agent design
  -&gt; related to: verification
  -&gt; related to: memory promotion
  -&gt; used in: weekly review workflow
  -&gt; supported by: tools, skills, cron
</pre></td></tr></tbody></table></code></pre></div></div>

<p>In code, a graph can tell me which functions call another function, which route reaches which service, or what might break if I change a model field.</p>

<p>In notes, a graph can show that an idea from a voice note connects to a blog draft, a work process, and a product decision.</p>

<p>In memory, a graph can connect a person, a preference, a repo, and a repeated workflow.</p>

<p>This is useful because LLMs are very good at language, but real work is full of relationships. The answer often depends less on one document and more on the path between several things.</p>

<p>Graph engineering gives the agent better maps.</p>

<p>But a map is not the journey.</p>

<h2 id="loop-first-graph-assisted">Loop-first, graph-assisted</h2>

<p>This is the distinction I keep coming back to.</p>

<p>For an AI assistant, graph engineering should usually support the loop. It should not replace the loop.</p>

<p>The graph helps the system retrieve the right context:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre>What code path is affected?
What note is connected to this idea?
What previous decision applies here?
What dependency might be touched?
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The loop decides what to do with that context:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>inspect
plan
change
test
report
promote
</pre></td></tr></tbody></table></code></pre></div></div>

<p>So my current rule is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>loop engineering for action
graph engineering for understanding
</pre></td></tr></tbody></table></code></pre></div></div>

<p>If the task is “fix this”, “draft this”, “review this”, “prepare this”, or “monitor this”, I want a loop.</p>

<p>If the task is “what is related?”, “what depends on this?”, “where is this used?”, or “what context matters?”, I want a graph.</p>

<p>The best systems combine them, but they should not confuse them.</p>

<h2 id="human-in-the-loop-is-not-a-weakness">Human-in-the-loop is not a weakness</h2>

<p>There is one more loop that matters: the human loop.</p>

<p>A lot of AI demos quietly assume that the best system is the one that removes the human as much as possible. Sometimes that is true. I do not want to manually rotate log files or retype the same invoice calculation forever.</p>

<p>But for many tasks, the human is not just a source of commands. The human is the owner of judgment.</p>

<p>Deploying to production, changing customer-facing infrastructure, exposing secrets, sending emails, publishing posts, making family decisions, spending money: these are not just technical actions. They carry responsibility.</p>

<p>A good agent should know which loop it is inside.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre>safe deterministic task
  -&gt; automate

repeated but judgment-heavy task
  -&gt; workflow + review

sensitive external side effect
  -&gt; explicit approval
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The goal is not maximum autonomy everywhere.</p>

<p>The goal is appropriate autonomy.</p>

<h2 id="the-nature-of-interaction-with-llms">The nature of interaction with LLMs</h2>

<p>This is the part I think people underestimate.</p>

<p>LLMs are not normal software components. They are not databases. They are not search engines. They are not humans either.</p>

<p>They are strange language engines that can temporarily inhabit a context, make plausible plans, call tools, explain uncertainty, and then lose the shape of the work unless we build structure around them.</p>

<p>So the interaction is not simply instruction and response.</p>

<p>It is setting up conditions for situated reasoning.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre>Give the model the right role.
Give it the right context.
Give it tools to observe reality.
Give it a loop that checks its work.
Give it memory for durable lessons.
Give it graphs for relationships.
Give it human approval where responsibility matters.
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Then the model becomes less like an oracle and more like a moving part in a thinking system.</p>

<p>That feels like the shift.</p>

<p>Not “AI will answer everything”.</p>

<p>More like: we are learning how to build systems where language, tools, memory, graphs, and human judgment keep passing state between each other.</p>

<h2 id="a-practical-stack">A practical stack</h2>

<p>If I had to explain the stack from bottom to top, I would describe it like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
</pre></td><td class="rouge-code"><pre>Prompt engineering
  Say the thing clearly.

Context engineering
  Bring the right facts into the room.

Tool engineering
  Let the model observe and act.

Memory engineering
  Preserve durable lessons without polluting the present.

Graph engineering
  Map relationships between code, notes, people, tasks, and decisions.

Loop engineering
  Turn all of the above into repeated, verified work.

Human judgment
  Decide what should be automated, reviewed, approved, or refused.
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Each layer solves a different failure mode.</p>

<p>Prompts reduce ambiguity. Context reduces ignorance. Tools reduce hallucination. Memory reduces repetition. Graphs reduce isolation. Loops reduce drift. Human judgment reduces irresponsible automation.</p>

<p>That is a more useful mental model than arguing whether “prompt engineering is dead”.</p>

<p>Prompting did not die. It became one layer in a larger system.</p>

<h2 id="where-i-am-landing">Where I am landing</h2>

<p>For Hermes, and probably for most serious personal agents, I think the best practice is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>loop-first, graph-assisted, human-governed
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Use loops to make the assistant reliable.</p>

<p>Use graphs to make the assistant aware of relationships.</p>

<p>Use memory and skills to make it improve over time.</p>

<p>Use tools and verification to keep it grounded.</p>

<p>Use human approval where the cost of being wrong is not just a bad answer.</p>

<p>That is the basic understanding I want to keep building.</p>

<p>The interesting question is no longer only “what should I type into the LLM?”</p>

<p>It is:</p>

<p>What kind of interaction am I building around it?</p>]]></content><author><name>Joey Wang</name></author><category term="ai" /><category term="engineering" /><category term="ai" /><category term="llm" /><category term="agents" /><category term="loop-engineering" /><category term="graph-engineering" /><category term="hermes" /><summary type="html"><![CDATA[A practical reflection on prompt engineering, context engineering, loop engineering, graph engineering, and what they reveal about the shape of human interaction with LLMs.]]></summary></entry><entry><title type="html">When an Always Free server gets smaller</title><link href="https://joeywang.github.io/posts/oracle-always-free-arm-downsize/" rel="alternate" type="text/html" title="When an Always Free server gets smaller" /><published>2026-08-08T10:45:00+00:00</published><updated>2026-08-08T10:45:00+00:00</updated><id>https://joeywang.github.io/posts/oracle-always-free-arm-downsize</id><content type="html" xml:base="https://joeywang.github.io/posts/oracle-always-free-arm-downsize/"><![CDATA[<p>Oracle’s Always Free ARM instance used to feel unusually generous.</p>

<p>For a long time, the practical mental model was simple: one Ampere A1 VM with 4 OCPUs and 24 GB of memory, or the same total split across smaller instances. That is enough to run a surprising amount of personal infrastructure: VPNs, a small Kubernetes cluster, databases, background workers, an AI agent control plane, local experiments, and whatever else slowly accumulates on a machine that is always on.</p>

<p>The current public shape is smaller. Oracle’s Always Free documentation now says Ampere A1 VM instances get the first <strong>1,500 OCPU hours</strong> and <strong>9,000 GB-hours</strong> per month for free. Oracle describes that as equivalent to <strong>2 OCPUs and 12 GB of memory</strong> for Always Free tenancies. The Oracle Cloud Free Tier page says the same thing more directly: Arm-based Ampere A1 cores and <strong>12 GB of memory</strong>, usable as one VM or two VMs.</p>

<p>That changes the engineering question.</p>

<p>It is no longer: “What can I fit on a very generous free ARM box?”</p>

<p>It is: “What deserves to be always-on if the honest budget is 2 cores and 12 GB?”</p>

<h2 id="the-reference-points">The reference points</h2>

<p>The useful public references are:</p>

<ul>
  <li>Oracle’s Always Free Resources documentation: <a href="https://docs.oracle.com/iaas/Content/FreeTier/freetier_topic-Always_Free_Resources.htm">https://docs.oracle.com/iaas/Content/FreeTier/freetier_topic-Always_Free_Resources.htm</a></li>
  <li>Oracle Cloud Free Tier page: <a href="https://www.oracle.com/cloud/free/">https://www.oracle.com/cloud/free/</a></li>
  <li>Oracle Ampere A1 compute page: <a href="https://www.oracle.com/cloud/compute/arm/">https://www.oracle.com/cloud/compute/arm/</a></li>
  <li>Oracle Cloud price list: <a href="https://www.oracle.com/cloud/price-list/">https://www.oracle.com/cloud/price-list/</a></li>
  <li>Oracle Cloud Free Tier FAQ: <a href="https://www.oracle.com/cloud/free/faq/">https://www.oracle.com/cloud/free/faq/</a></li>
</ul>

<p>The important lines, as of this writing:</p>

<ul>
  <li>Always Free Ampere A1 VM usage: <strong>1,500 OCPU hours</strong> and <strong>9,000 GB-hours</strong> per month.</li>
  <li>For Always Free tenancies, Oracle says that is equivalent to <strong>2 OCPUs and 12 GB of memory</strong>.</li>
  <li>Oracle’s Free Tier page lists Arm compute as <strong>12 GB of memory usable as 1 VM or 2 VMs</strong>.</li>
  <li>Oracle’s Ampere page lists A1 pricing at <strong>$0.01 per OCPU-hour</strong> and <strong>$0.0015 per GB-hour</strong>.</li>
  <li>The FAQ says Always Free services are available for an unlimited period, but also says Free Tier availability is subject to capacity limits and that listed capacity estimates can change.</li>
</ul>

<p>That last pair is the tension.</p>

<h2 id="the-contract-shaped-expectation">The contract-shaped expectation</h2>

<p>I do not think most people read “Always Free” as a benchmark number. They read it as a promise.</p>

<p>Not necessarily a promise that every quota will be frozen forever. Cloud providers change instance families, limits, abuse controls, capacity policies, and commercial packaging all the time. But “Always Free” creates a stronger expectation than “trial” or “promotional credit”. If a provider trains users to build around a free shape, and that shape later becomes smaller, the change feels less like ordinary pricing maintenance and more like a contract-shaped disappointment.</p>

<p>There is a legal version of that discussion, and I am not trying to make it here. The practical engineering version is enough:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>Marketing promise: this is Always Free
Operational reality: capacity and limits can change
User response: design the system as if the shape is not guaranteed
</pre></td></tr></tbody></table></code></pre></div></div>

<p>That sounds cynical, but it is just the cloud version of not treating someone else’s free tier as your only production plan.</p>

<p>The fair reading is probably this: the account can still have Always Free resources for an unlimited period, but the resource envelope is not an immutable interface. If you need a particular shape, you should budget for it or have a downgrade plan.</p>

<h2 id="the-cost-of-pretending-nothing-changed">The cost of pretending nothing changed</h2>

<p>The old practical shape was 4 OCPUs and 24 GB of RAM running continuously.</p>

<p>At Oracle’s published Ampere A1 rates:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>OCPU:   $0.01 per OCPU-hour
Memory: $0.0015 per GB-hour
</pre></td></tr></tbody></table></code></pre></div></div>

<p>A full 4 OCPU / 24 GB machine for an average 730-hour month is roughly:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>4 OCPUs * 730 hours * $0.01      = $29.20
24 GB   * 730 hours * $0.0015    = $26.28
Gross monthly shape cost         = $55.48
</pre></td></tr></tbody></table></code></pre></div></div>

<p>If the first 1,500 OCPU-hours and 9,000 GB-hours are free, then keeping the old 4 / 24 shape continuously means paying for the part above the new free envelope:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>Billable OCPU-hours: (4 * 730) - 1,500 = 1,420
Billable GB-hours:   (24 * 730) - 9,000 = 8,520

OCPU overage:   1,420 * $0.01    = $14.20
Memory overage: 8,520 * $0.0015  = $12.78
Estimated monthly overage        = $26.98
</pre></td></tr></tbody></table></code></pre></div></div>

<p>For a 31-day month the estimate is about <strong>$28.04</strong>. For a 30-day month it is about <strong>$26.22</strong>.</p>

<p>That is not an outrageous bill. In fact, for a 4-core ARM VM with 24 GB of RAM, it is still cheap.</p>

<p>But it changes the category. A free hobby host becomes a small paid server. That may be perfectly fine, but it should be an explicit decision, not something discovered after the bill arrives.</p>

<h2 id="my-target-shape-12-gb-not-nostalgia">My target shape: 12 GB, not nostalgia</h2>

<p>I decided to treat 12 GB as the real design constraint.</p>

<p>The point was not to recreate the old host badly. The point was to decide what the host is actually for.</p>

<p>For me, the always-on role is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre>small ARM host
  -&gt; private access paths
  -&gt; basic shell and development access
  -&gt; lightweight automation
  -&gt; scheduled maintenance
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Everything else has to justify itself.</p>

<p>That means the host should keep only the boring access and automation layer always running. Heavier platform services, databases, container workloads, local model servers, and one-off experiment processes should become explicitly on-demand.</p>

<p>That is the important shift: not “can I squeeze it all in?” but “should this be always-on?”</p>

<h2 id="shutting-things-down-without-deleting-them">Shutting things down without deleting them</h2>

<p>The safe version of downsizing is not to uninstall everything in a panic.</p>

<p>For each service, I want one of three states:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>must-run      -&gt; enabled and monitored
on-demand     -&gt; stopped and disabled, data retained
retired       -&gt; backed up, documented, then removed later
</pre></td></tr></tbody></table></code></pre></div></div>

<p>For this host, the first pass was mostly “on-demand”: stop services, disable boot-time autostart, keep the data.</p>

<p>A service table is more useful than a vague note:</p>

<table>
  <thead>
    <tr>
      <th>Component</th>
      <th>New default</th>
      <th>Start when needed</th>
      <th>Stop after use</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>k3s</td>
      <td>stopped + disabled</td>
      <td><code class="language-plaintext highlighter-rouge">sudo systemctl start k3s</code></td>
      <td><code class="language-plaintext highlighter-rouge">sudo systemctl stop k3s &amp;&amp; sudo k3s-killall.sh</code></td>
    </tr>
    <tr>
      <td>MySQL</td>
      <td>stopped + disabled</td>
      <td><code class="language-plaintext highlighter-rouge">sudo systemctl start mysql</code></td>
      <td><code class="language-plaintext highlighter-rouge">sudo systemctl stop mysql</code></td>
    </tr>
    <tr>
      <td>Multipass</td>
      <td>stopped + disabled</td>
      <td><code class="language-plaintext highlighter-rouge">sudo systemctl start snap.multipass.multipassd.service</code></td>
      <td><code class="language-plaintext highlighter-rouge">sudo systemctl stop snap.multipass.multipassd.service</code></td>
    </tr>
    <tr>
      <td>WARP</td>
      <td>stopped + disabled</td>
      <td><code class="language-plaintext highlighter-rouge">sudo systemctl start warp-svc.service</code></td>
      <td><code class="language-plaintext highlighter-rouge">sudo systemctl stop warp-svc.service</code></td>
    </tr>
    <tr>
      <td>Local LLM server</td>
      <td>on-demand</td>
      <td>start the model server for a bounded task</td>
      <td>stop it when the task ends</td>
    </tr>
  </tbody>
</table>

<p>The Kubernetes line matters. Stopping <code class="language-plaintext highlighter-rouge">k3s</code> is not always enough, because container shims, mounts, CNI state, and iptables rules can linger. <code class="language-plaintext highlighter-rouge">k3s-killall.sh</code> is useful because it clears runtime state without uninstalling k3s or deleting persistent data.</p>

<p>That gives me a reversible downgrade. If I need the cluster for an experiment, I can start it. If I do not, it is not allowed to quietly consume RAM every day.</p>

<h2 id="verification-before-resizing">Verification before resizing</h2>

<p>Before changing the cloud shape, I want evidence that the smaller role is already true on the larger host.</p>

<p>The checklist is boring on purpose:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
</pre></td><td class="rouge-code"><pre><span class="nb">hostname
uname</span> <span class="nt">-m</span>
<span class="nb">nproc
</span>free <span class="nt">-h</span>
swapon <span class="nt">--show</span>

<span class="nb">df</span> <span class="nt">-h</span> /
systemctl is-active &lt;must-run-services&gt; <span class="o">||</span> <span class="nb">true
</span>systemctl is-enabled &lt;must-run-services&gt; <span class="o">||</span> <span class="nb">true
</span>systemctl is-active &lt;on-demand-services&gt; <span class="o">||</span> <span class="nb">true
</span>systemctl is-enabled &lt;on-demand-services&gt; <span class="o">||</span> <span class="nb">true
</span>ss <span class="nt">-tulpn</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The expected result is:</p>

<ul>
  <li>remote access is reachable.</li>
  <li>the required private access paths are present.</li>
  <li>the automation layer still answers a smoke test.</li>
  <li>heavier optional services are inactive and disabled.</li>
  <li>unnecessary listeners are gone.</li>
  <li>available memory looks healthy before the resize.</li>
</ul>

<p>The resize itself is still a stop/start event. If the host runs the automation path that talks to me, I should expect that path to be offline during the shape change unless I intentionally fail it over first.</p>

<h2 id="backups-are-part-of-the-shape-change">Backups are part of the shape change</h2>

<p>A smaller server is not just a smaller server. It is also a forcing function for deciding what is precious.</p>

<p>Before resizing, I want backups for the things that would be annoying or risky to reconstruct:</p>

<ul>
  <li>VPN configuration</li>
  <li>SSH configuration</li>
  <li>firewall and network configuration</li>
  <li>systemd units/drop-ins for must-run services</li>
  <li>Hermes configuration, scripts, cron jobs, plugins, and skills</li>
  <li>enough package/system state to understand how the host was assembled</li>
</ul>

<p>The backup should be encrypted or permission-restricted, checksummed, and copied somewhere other than the host being resized. It should also be treated as sensitive. A restore archive for VPN, SSH, and agent configuration is not a blog artifact; it is effectively infrastructure authority in a tarball.</p>

<p>The goal is not to make the host precious. It is the opposite: make the host disposable enough that a failed resize is inconvenient, not existential.</p>

<h2 id="the-lesson">The lesson</h2>

<p>The Oracle change is annoying because the old free ARM shape was genuinely useful. A 4 OCPU / 24 GB always-on machine is enough to hide architectural laziness for a long time.</p>

<p>The smaller 2 OCPU / 12 GB shape is less forgiving, but it is still useful if the host has a clear job.</p>

<p>My rule now is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>free tier = great control plane
free tier != dumping ground
</pre></td></tr></tbody></table></code></pre></div></div>

<p>If I need the old shape continuously, the honest cost is roughly $27 to $28 per month above the new Always Free envelope. That is cheap enough to consider and expensive enough to make me clean up first.</p>

<p>So the plan is to downsize the host, not the responsibility:</p>

<ul>
  <li>keep VPN, SSH, and Hermes always-on</li>
  <li>move databases, Kubernetes, local models, and helper daemons to on-demand</li>
  <li>verify the lean state before resizing</li>
  <li>keep sensitive restore backups elsewhere</li>
  <li>treat “Always Free” as a useful offer, not a capacity contract I build my life around</li>
</ul>

<p>That is a healthier design anyway. The cloud provider changed the limits; the useful response is to make the machine’s purpose smaller and clearer.</p>]]></content><author><name>Joey Wang</name></author><category term="engineering" /><category term="devops" /><category term="devops" /><category term="cloud" /><category term="oracle" /><category term="arm" /><category term="self-hosting" /><category term="cost" /><summary type="html"><![CDATA[What Oracle's smaller Ampere A1 Always Free limits mean for a self-hosted ARM server, and how I reshaped mine around a 12 GB memory budget.]]></summary></entry><entry><title type="html">The CI failures that moved every time you reran them</title><link href="https://joeywang.github.io/posts/the-ci-failure-that-moved-every-rerun/" rel="alternate" type="text/html" title="The CI failures that moved every time you reran them" /><published>2026-08-07T08:45:00+00:00</published><updated>2026-08-07T08:45:00+00:00</updated><id>https://joeywang.github.io/posts/the-ci-failure-that-moved-every-rerun</id><content type="html" xml:base="https://joeywang.github.io/posts/the-ci-failure-that-moved-every-rerun/"><![CDATA[<p>Every time we reran the failing CI job, a different spec failed. Same error message, different victim. That alone should have told us what was happening, but it took a few reruns and a merged pull request to see the first bug.</p>

<p>Then CI moved again.</p>

<p>This is the story of a flaky test suite that wasn’t one problem. It was a small cluster of assumptions that only became visible under parallel CI and a newer browser.</p>

<h2 id="the-symptom">The symptom</h2>

<p>Our Rails app runs its feature specs in CI with <code class="language-plaintext highlighter-rouge">parallel_tests</code> — seven RSpec processes sharing one database setup. A normal merge to <code class="language-plaintext highlighter-rouge">main</code> triggers the full suite.</p>

<p>After merging a small mailer change, the CI run came back red:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>X execution expired
JUnit Test Report: ./spec/features/admin/registrations_spec.rb#32
</pre></td></tr></tbody></table></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">execution expired</code> is Ruby’s <code class="language-plaintext highlighter-rouge">Timeout::Error</code>. The spec that failed was downloading a CSV and waiting for the file to appear. I looked at the spec locally, ran it, and it passed in five seconds.</p>

<p>Fine. Flaky spec. Rerun the failed job and move on.</p>

<p>The rerun failed again. This time on two <em>different</em> specs:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre>X execution expired
JUnit Test Report: ./spec/features/admin/registrations_spec.rb#109
X execution expired
JUnit Test Report: ./spec/features/admin/programs_spec.rb#167
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Same error. Different lines. None of the three specs had anything to do with the mailer change I had just merged. (I’m deliberately using placeholder spec names and leaving out run IDs here — the numbers and names don’t matter, the shape does.)</p>

<h2 id="the-pattern-hiding-in-the-failures">The pattern hiding in the failures</h2>

<p>By now the shape of the problem was hard to miss. Every failure was:</p>

<ul>
  <li>a feature spec that downloads a file (CSV export, file attachment, PDF)</li>
  <li>timing out at exactly the same 60-second threshold</li>
  <li>a <em>different</em> spec each run</li>
</ul>

<p>All three specs used the same test helper:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
</pre></td><td class="rouge-code"><pre><span class="k">module</span> <span class="nn">DownloadHelpers</span>
  <span class="no">TIMEOUT</span> <span class="o">=</span> <span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s1">'DOWNLOAD_TIMEOUT'</span><span class="p">,</span> <span class="mi">60</span><span class="p">).</span><span class="nf">to_i</span>
  <span class="no">PATH</span> <span class="o">=</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">root</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s1">'tmp'</span><span class="p">,</span> <span class="s1">'downloads'</span><span class="p">).</span><span class="nf">freeze</span>

  <span class="k">def</span> <span class="nf">wait_for_download</span>
    <span class="no">Timeout</span><span class="p">.</span><span class="nf">timeout</span><span class="p">(</span><span class="no">TIMEOUT</span><span class="p">)</span> <span class="k">do</span>
      <span class="nb">sleep</span> <span class="mf">0.5</span> <span class="k">until</span> <span class="n">downloaded?</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>And here is the part that made it intermittent: in <code class="language-plaintext highlighter-rouge">rails_helper.rb</code>, every single feature spec clears the download folder before it runs:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="n">config</span><span class="p">.</span><span class="nf">before</span><span class="p">(</span><span class="ss">:each</span><span class="p">,</span> <span class="ss">type: :feature</span><span class="p">)</span> <span class="k">do</span>
  <span class="no">DownloadHelpers</span><span class="p">.</span><span class="nf">clear_downloads</span>
<span class="k">end</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">clear_downloads</code> does <code class="language-plaintext highlighter-rouge">FileUtils.rm_r</code> on everything in <code class="language-plaintext highlighter-rouge">tmp/downloads</code>.</p>

<p>Now think about what happens with seven parallel processes. Process 3 clicks “Download” and starts polling for the file. Process 5 starts a feature spec, runs <code class="language-plaintext highlighter-rouge">before(:each)</code>, and deletes every file in the shared <code class="language-plaintext highlighter-rouge">tmp/downloads</code> folder. Process 3’s file vanishes before the poll sees it. It waits, polls, waits, and dies 60 seconds later with <code class="language-plaintext highlighter-rouge">execution expired</code>.</p>

<p>It was a race. A shared mutable directory, seven processes, and a cleanup step that assumes it owns the folder. No wonder the failure moved around — it depended entirely on which processes happened to overlap in that window.</p>

<h2 id="why-local-runs-were-always-green">Why local runs were always green</h2>

<p>This is the part that made the whole thing confusing at first. Every spec passed on my machine, every time.</p>

<p>Because on my machine there is exactly one RSpec process. It clears the download folder, downloads the file, reads it, and clears again. There is nobody else deleting files out from under it. The race needs two processes, and locally there is only one.</p>

<p>The same commit that was green in one CI run was red in another. That should have been the tell. A genuinely broken spec fails the same way every time. A spec that fails differently each run is usually a victim of something else.</p>

<h2 id="the-fix-give-every-process-its-own-folder">The fix: give every process its own folder</h2>

<p>The app already had the answer waiting in <code class="language-plaintext highlighter-rouge">config/application.rb</code>. Under <code class="language-plaintext highlighter-rouge">parallel_tests</code>, each process gets a <code class="language-plaintext highlighter-rouge">TEST_ENV_NUMBER</code>, and the app already uses it to give each process its own Redis database:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="k">if</span> <span class="no">ENV</span><span class="p">[</span><span class="s1">'RAILS_ENV'</span><span class="p">]</span> <span class="o">==</span> <span class="s1">'test'</span> <span class="o">&amp;&amp;</span> <span class="no">ENV</span><span class="p">[</span><span class="s1">'TEST_ENV_NUMBER'</span><span class="p">].</span><span class="nf">to_s</span> <span class="o">!=</span> <span class="s1">''</span>
  <span class="c1"># shift each process onto its own redis db</span>
<span class="k">end</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The download helper just needed the same treatment:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre><span class="k">module</span> <span class="nn">DownloadHelpers</span>
  <span class="n">test_env_number</span> <span class="o">=</span> <span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s1">'TEST_ENV_NUMBER'</span><span class="p">,</span> <span class="kp">nil</span><span class="p">).</span><span class="nf">to_s</span>
  <span class="no">PATH</span> <span class="o">=</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">root</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span>
    <span class="s1">'tmp'</span><span class="p">,</span>
    <span class="s1">'downloads'</span><span class="p">,</span>
    <span class="n">test_env_number</span><span class="p">.</span><span class="nf">empty?</span> <span class="p">?</span> <span class="s1">''</span> <span class="p">:</span> <span class="s2">"process_</span><span class="si">#{</span><span class="n">test_env_number</span><span class="si">}</span><span class="s2">"</span>
  <span class="p">).</span><span class="nf">freeze</span>
<span class="k">end</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Process 3 downloads into <code class="language-plaintext highlighter-rouge">tmp/downloads/process_3</code>, process 5 clears <code class="language-plaintext highlighter-rouge">tmp/downloads/process_5</code>. They never touch each other’s files. I also made <code class="language-plaintext highlighter-rouge">clear_downloads</code> create the directory first, because Chrome will not reliably create a missing nested download folder on its own.</p>

<p>One commit, one file changed:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>test: isolate download dir per parallel test process
</pre></td></tr></tbody></table></code></pre></div></div>

<p>CI came back green. The flaky suite stopped failing in that particular way, because it had never really been flaky — it had been a shared-state bug that only existed when the suite ran in parallel.</p>

<p>But that was not the end of the story.</p>

<h2 id="the-second-pattern-stale-browser-nodes">The second pattern: stale browser nodes</h2>

<p>After the download-folder fix, the failures changed shape. They were no longer clean 60-second timeouts. Instead, feature specs sometimes died while Capybara was interacting with dynamic widgets:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>unknown error: unhandled inspector error:
{"code":-32000,"message":"Node with given id does not belong to the document"}
</pre></td></tr></tbody></table></code></pre></div></div>

<p>That is Chrome telling Selenium that a DOM node disappeared between lookup and action. In older or simpler cases, Selenium reports this as a normal stale-element error, and Capybara knows how to retry it inside its synchronization loop. This one came through as a generic Selenium unknown error, so Capybara treated it as fatal.</p>

<p>The tempting fix is dangerous: catch the message and let Capybara retry forever until the global wait expires.</p>

<p>That makes the suite quieter, but it also makes CI slower. A real failure now burns the whole wait window before it tells you anything useful.</p>

<p>The fix I wanted was narrower:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
</pre></td><td class="rouge-code"><pre><span class="k">module</span> <span class="nn">CapybaraChromeStaleNodeFix</span>
  <span class="no">CDP_STALE_NODE_MESSAGE</span> <span class="o">=</span> <span class="s1">'does not belong to the document'</span>
  <span class="no">MAX_CDP_STALE_NODE_RETRIES</span> <span class="o">=</span> <span class="mi">2</span>
  <span class="no">CDP_STALE_NODE_RETRY_WINDOW_SECONDS</span> <span class="o">=</span> <span class="mf">0.5</span>

  <span class="k">def</span> <span class="nf">catch_error?</span><span class="p">(</span><span class="n">error</span><span class="p">,</span> <span class="n">errors</span> <span class="o">=</span> <span class="kp">nil</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">chrome_stale_node_error?</span><span class="p">(</span><span class="n">error</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">retry_chrome_stale_node_error?</span>
      <span class="kp">true</span>
    <span class="k">else</span>
      <span class="k">super</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The important part is not the exact constants. It is the shape of the guard:</p>

<ul>
  <li>only this Chrome CDP stale-node message is treated as retryable</li>
  <li>only a short burst is retried</li>
  <li>repeated failures fail fast instead of consuming the full Capybara wait</li>
</ul>

<p>That is the difference between stabilizing a transient browser race and hiding a broken spec behind a slower timeout.</p>

<h2 id="the-third-pattern-waits-that-were-too-broad">The third pattern: waits that were too broad</h2>

<p>We also had explicit helpers like <code class="language-plaintext highlighter-rouge">wait_for_ajax</code> and <code class="language-plaintext highlighter-rouge">wait_for_page_to_settle</code>. They used <code class="language-plaintext highlighter-rouge">Capybara.default_max_wait_time</code>, which is reasonable for user-facing assertions but too generous for a tiny internal settle check.</p>

<p>If the page is basically ready and we are only waiting for current AJAX requests or a progress bar to disappear, we should not spend the same budget as a real browser assertion.</p>

<p>So those helpers moved to a small explicit wait:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre><span class="no">CAPYBARA_FAST_WAIT_SECONDS</span> <span class="o">=</span> <span class="mf">1.0</span>
<span class="no">CAPYBARA_POLL_INTERVAL_SECONDS</span> <span class="o">=</span> <span class="mf">0.05</span>

<span class="k">def</span> <span class="nf">wait_for_ajax</span><span class="p">(</span><span class="n">timeout_seconds</span> <span class="o">=</span> <span class="no">CAPYBARA_FAST_WAIT_SECONDS</span><span class="p">)</span>
  <span class="n">wait_until_with_timeout</span><span class="p">(</span><span class="n">timeout_seconds</span><span class="p">,</span> <span class="s1">'Timed out waiting for ajax requests to finish'</span><span class="p">)</span> <span class="k">do</span>
    <span class="n">finished_all_ajax_requests?</span>
  <span class="k">end</span>
<span class="k">end</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>I also avoided wrapping browser calls with Ruby’s <code class="language-plaintext highlighter-rouge">Timeout.timeout</code>. Interrupting Selenium in the middle of a WebDriver call is not a great failure mode. A monotonic deadline loop is boring, but boring is good here.</p>

<h2 id="the-last-two-flakes-were-test-assumptions">The last two flakes were test assumptions</h2>

<p>One CI run after the retry change still failed, this time with two ordinary-looking assertion failures.</p>

<p>The first was an announcement-editing spec. The spec tried to remove all selected program filters by mutating Selectize’s hidden input directly with JavaScript, then submitting the form. The visual labels disappeared, but the submitted form value could still contain the old selected IDs under CI timing.</p>

<p>That is not a product bug. It is a test that bypassed the UI and then expected UI state, Selectize state, and form state to agree.</p>

<p>The fix was to exercise the path a user actually takes: remove the visible labels, wait for the selected options to disappear, and submit the specific admin form.</p>

<p>The second was a registration spec. After submitting a sign-in form, it immediately asserted that the database association existed. Locally it usually did. In CI, the browser had not always completed the redirect/session flow before the assertion ran.</p>

<p>The fix was not another sleep. It was to wait for the visible post-login page state first:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="n">fill_in_sign_in_form</span><span class="p">(</span><span class="n">user</span><span class="p">.</span><span class="nf">email</span><span class="p">)</span>
<span class="n">expect</span><span class="p">(</span><span class="n">page</span><span class="p">).</span><span class="nf">to</span> <span class="n">have_text</span><span class="p">(</span><span class="s1">'Programs'</span><span class="p">)</span>
<span class="n">expect</span><span class="p">(</span><span class="n">user</span><span class="p">.</span><span class="nf">reload</span><span class="p">.</span><span class="nf">registration_items</span><span class="p">.</span><span class="nf">count</span><span class="p">).</span><span class="nf">to</span> <span class="n">eq</span> <span class="mi">1</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Feature specs should synchronize on what the user can see. If the user-visible page is not there yet, the test has no business asserting the side effect as if the flow had finished.</p>

<h2 id="the-lessons">The lessons</h2>

<ol>
  <li>
    <p><strong>A different spec failing every rerun is itself a clue.</strong> A broken test fails the same way every time. A moving failure points at shared state, browser timing, or a helper assumption that many tests share.</p>
  </li>
  <li>
    <p><strong>Check what the tests share before blaming the tests.</strong> Parallel test suites run the same code in the same working directory. Files, folders, ports, Redis keys, environment variables, browser helpers — anything shared is a candidate. In our case the app had already solved this for databases and Redis; the downloads folder was simply the one thing nobody had partitioned yet.</p>
  </li>
  <li>
    <p><strong>Timeouts that always hit the same limit are a smell.</strong> A 60-second timeout that always burns the full 60 seconds is not a slow test. It is a test waiting for something that will never arrive. Retrying a browser error through the full Capybara wait can create the same problem in a different form.</p>
  </li>
  <li>
    <p><strong>Local green runs do not clear CI.</strong> Single-process local runs cannot reproduce races that need concurrency. If a spec only fails under parallel CI, try reproducing it with two processes and a shared resource, or just look for the shared resource directly.</p>
  </li>
  <li>
    <p><strong>Make retries bounded and specific.</strong> A retry should describe the transient failure it is meant to absorb. If it catches too much, or waits too long, it becomes a performance bug and hides useful failures.</p>
  </li>
  <li>
    <p><strong>Synchronize feature specs on user-visible state.</strong> Waiting for a page heading, menu, or result is better than asserting the database immediately after a click. Capybara is good at waiting for the browser; use that instead of racing the browser from Ruby.</p>
  </li>
</ol>

<p>The first fix was ten lines in a test helper. The later fixes were smaller but more subtle: a bounded browser retry, shorter explicit waits, and two feature specs changed to synchronize on the UI instead of internal state.</p>

<p>The expensive part was not the code. It was accepting that “flaky CI” was not a single diagnosis. It was a queue of small incorrect assumptions, and each green run only proved that we had removed the current one.</p>]]></content><author><name>Joey Wang</name></author><category term="engineering" /><category term="engineering" /><category term="testing" /><category term="ci" /><category term="ruby" /><category term="rails" /><category term="debugging" /><summary type="html"><![CDATA[How a moving set of Rails CI flakes exposed shared download state, Chrome/Selenium stale-node retries, and feature specs that asserted before the browser had settled.]]></summary></entry></feed>