<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: emi</title>
    <description>The latest articles on DEV Community by emi (@emalia).</description>
    <link>https://dev.to/emalia</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4144800%2Fc1ca2bab-1753-4c5e-93f5-a95704cc328b.jpg</url>
      <title>DEV Community: emi</title>
      <link>https://dev.to/emalia</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/emalia"/>
    <language>en</language>
    <item>
      <title>upgrading and recovering my self-hosted openclaw agent + telegram bot</title>
      <dc:creator>emi</dc:creator>
      <pubDate>Sat, 26 Sep 2026 23:20:53 +0000</pubDate>
      <link>https://dev.to/emalia/upgrading-and-recovering-my-self-hosted-openclaw-agent-telegram-bot-oj</link>
      <guid>https://dev.to/emalia/upgrading-and-recovering-my-self-hosted-openclaw-agent-telegram-bot-oj</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;br&gt;
I currently host an openclaw agent on a VM and use it for various coding tasks and project spec generation. it was feeling slow so I ssh-ed into the VM myself and asked a claude code agent to audit and upgrade it. this post is about what broke, what didn't, and how I'd do it next time.&lt;/p&gt;

&lt;p&gt;what happened:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the upgrade had failed because plugin builds ran in a 3.8GB RAM-backed &lt;code&gt;/tmp&lt;/code&gt; and ran out of space.&lt;/li&gt;
&lt;li&gt;a session migration deadlock that got worse when I moved files, because openclaw fingerprints inodes.&lt;/li&gt;
&lt;li&gt;the agent itself resuming its interrupted task after every restart and force-killing the gateway under me while I was repairing it.&lt;/li&gt;
&lt;li&gt;4 database and storage ports from side projects published to the public internet, because docker bypasses ufw.&lt;/li&gt;
&lt;li&gt;3 "watchdog" cron jobs the agent had created that burned about 1,340 LLM turns in 1 week polling for status, versus a few dozen turns of actual conversation.&lt;/li&gt;
&lt;li&gt;a cleanup timer I wrote that deleted directories the running gateway depended on, breaking every message for 2 hours.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;what I upgraded/changed: openclaw to the most current release, primary model moved to GPT-6 Astra, container ports bound to localhost with firewall rules in &lt;code&gt;DOCKER-USER&lt;/code&gt;, 4GB swap, a 1.2GB log database cut to 153MB, 51 plugins trimmed to 12, unattended LLM runs cut from ~1,340 a week to 2 small scheduled jobs, and daily backups plus version control for the agent's memory files, neither of which existed before.&lt;/p&gt;

&lt;p&gt;lessons learned: keep an out-of-band way in, bind containers to loopback because docker ignores ufw, treat a resumable agent with shell access as a second operator, measure cost as quota by querying task runs rather than billing, give every automation a timeout, and never automate deletion of something a service created until you have restarted the service and watched what it recreates.&lt;/p&gt;


&lt;h2&gt;
  
  
  upgrading, hardening, and fine tuning a self-hosted openclaw agent.
&lt;/h2&gt;

&lt;p&gt;the setup: openclaw gateway running as a root user-level systemd service on an 8GiB, 4-core linux VM, talks to a telegram bot, uses codex subscription auth. several project repos and docker containers live on the same host.&lt;/p&gt;
&lt;h2&gt;
  
  
  timeline
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;step&lt;/th&gt;
&lt;th&gt;what happened&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;I asked the agent to audit itself, then to execute the remediation&lt;/td&gt;
&lt;td&gt;agent started &lt;code&gt;openclaw update&lt;/code&gt; and went silent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;upgrade installed 2026.9.5, stopped the gateway, post-upgrade doctor aborted on the codex plugin&lt;/td&gt;
&lt;td&gt;telegram dead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;the plugin build failed with ENOSPC because /tmp was a 3.8GiB RAM disk&lt;/td&gt;
&lt;td&gt;moved TMPDIR to disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;session migration blocked by leftover JSONL transcripts; a file move changed inodes and broke the migration fingerprint&lt;/td&gt;
&lt;td&gt;fixed fingerprint in sqlite, doctor passed, gateway back&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;discovered the agent itself was concurrently running repair scripts and force-killing the gateway&lt;/td&gt;
&lt;td&gt;coordinated instead of fighting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;firewall for docker ports, swap file, log pruning, plugin build cleanup&lt;/td&gt;
&lt;td&gt;host stabilized&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;switched primary model to GPT-6 Astra&lt;/td&gt;
&lt;td&gt;verified with test turns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;efficiency audit: 1,300+ LLM turns by 3 watchdog cron jobs in a week&lt;/td&gt;
&lt;td&gt;added rules to ensure heartbeats were bounded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;my own cleanup timer deleted live plugin build dirs; every turn failed for ~2 hours&lt;/td&gt;
&lt;td&gt;rewrote the policy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  1. how I diagnosed the unresponsive agent after the upgrade failed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;symptom.&lt;/strong&gt; mid-upgrade, my telegram bot stopped responding to me. no error, nothing loading, it just stopped responding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;how to look.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw &lt;span class="nt"&gt;--version&lt;/span&gt;                              &lt;span class="c"&gt;# what actually got installed&lt;/span&gt;
systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; list-units &lt;span class="s1"&gt;'openclaw*'&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt;   &lt;span class="c"&gt;# is the gateway running?&lt;/span&gt;
journalctl &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; openclaw-gateway &lt;span class="nt"&gt;-n&lt;/span&gt; 200    &lt;span class="c"&gt;# what happened before it died&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the gateway unit was &lt;code&gt;inactive (dead)&lt;/code&gt;. the upgrade log — a transient &lt;code&gt;systemd-run&lt;/code&gt; unit the agent had created for itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
Updated post-plugin Doctor failed: Plugin "codex" state migration is pending:
The configured plugin package is missing or has not converged.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;what happened here&lt;/strong&gt; &lt;code&gt;openclaw update&lt;/code&gt; stops the gateway, runs the doctor, and only restarts on success. so when the doctor failed, nothing restarted the gateway, and there was no automatic rollback. the upgrade just left the thing off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;what I learned.&lt;/strong&gt; this is probably obvious, and originally I wasn't going to let the agent upgrade itself, I just wanted to use it to help make the plans for the upgrade to hand off to another agent, but once it made the plans it seemed confident it could just do it, so I was like okay cool go for it. that was a mistake. so the learning is: do NOT let the agent upgrade itself over the same channel you use to talk to it (unless you have a second way in, I guess if I had had a second telegram bot set up I could have then used that to fix it, but I didn't so I had to wait until I was home to manually ssh in and fix it). additionally, this is risky because the second it stops the gateway, it has no way to tell you what went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. ENOSPC (error no space) on a disk with 41GB free
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;symptom.&lt;/strong&gt; after I got the gateway running again, the codex plugin refused to load with the error: &lt;code&gt;ENOSPC: no space left on device, write&lt;/code&gt;. but &lt;code&gt;df&lt;/code&gt; showed plenty of room.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;cause.&lt;/strong&gt; &lt;code&gt;/tmp&lt;/code&gt; was a tmpfs (a RAM disk) of 3.8GiB. and it was already 73% full of old temp files. I didn't realize this, but openclaw builds a copy of each plugin package under &lt;code&gt;os.tmpdir()&lt;/code&gt; every time it loads one, and the codex plugin's copy is 342MB. so a few concurrent builds overflowed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;fix.&lt;/strong&gt; a systemd drop-in for the gateway service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.config/systemd/user/openclaw-gateway.service.d/20-cache-paths.conf
&lt;/span&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;TMPDIR=/var/tmp/openclaw-tmp&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and &lt;code&gt;export TMPDIR=/var/tmp/openclaw-tmp&lt;/code&gt; before any &lt;code&gt;openclaw&lt;/code&gt; CLI command, because the CLI builds plugins too. drop-ins survive &lt;code&gt;openclaw gateway install --force&lt;/code&gt;, which rewrites the main unit file out from under you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;lesson.&lt;/strong&gt; check &lt;code&gt;findmnt /tmp&lt;/code&gt; on any VM before you run large builds there. a RAM-backed &lt;code&gt;/tmp&lt;/code&gt; also quietly eats memory you think you have: 2.8GiB of the "used" RAM on this box was temp files.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. the session migration deadlock
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;symptom.&lt;/strong&gt; &lt;code&gt;openclaw doctor --fix&lt;/code&gt; kept stopping in the same place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SQLite-backed session still has an unverified active JSONL transcript file ...
Doctor stopped because a state migration refused to continue.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the suggested &lt;code&gt;openclaw doctor --session-sqlite recover&lt;/code&gt; did nothing at all (&lt;code&gt;restored=0&lt;/code&gt;). meanwhile the codex plugin's "retained state migration" couldn't finish until this settled, so the gateway stayed in degraded mode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;how I verified the files were safe to move.&lt;/strong&gt; for each flagged JSONL, I compared its line count against the event count in sqlite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
import sqlite3, os
base = os.path.expanduser('~/.openclaw/agents/main')
c = sqlite3.connect(f"file:{base}/agent/openclaw-agent.sqlite?mode=ro", uri=True)
counts = dict(c.execute('select session_id, count(*) from transcript_events group by session_id'))
for fn in os.listdir(f"{base}/sessions"):
    if fn.endswith('.jsonl') and fn[:-6] in counts:
        n = sum(1 for _ in open(f"{base}/sessions/{fn}", 'rb'))
        print(fn, n, counts[fn[:-6]], 'MATCH' if n == counts[fn[:-6]] else 'MISMATCH')
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;all 55 matched exactly. moving them to a backup folder let the codex migration complete on the next &lt;code&gt;doctor --fix&lt;/code&gt;, which felt like the end of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;what went wrong next.&lt;/strong&gt; the doctor then refused with &lt;code&gt;retained_plugin_source_conflict: source changed&lt;/code&gt;. I copied the files back byte-for-byte and it still refused. the reason is that the migration fingerprint in &lt;code&gt;~/.openclaw/state/openclaw.sqlite&lt;/code&gt; — tables &lt;code&gt;migration_runs&lt;/code&gt; and &lt;code&gt;migration_sources&lt;/code&gt; — records &lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;ino&lt;/code&gt;, &lt;code&gt;mtimeNs&lt;/code&gt;, &lt;code&gt;size&lt;/code&gt;, and &lt;code&gt;sha256&lt;/code&gt; for every source file, and a move-and-copy changes the inode. the bytes were identical and the fingerprint still didn't match. I backed up the state DB and updated the 55 &lt;code&gt;ino&lt;/code&gt; values in the stored JSON to the current ones. the doctor completed, archived 129 legacy transcripts on its own, and the gateway came up clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;lesson.&lt;/strong&gt; do NOT move openclaw session files, even temporarily. if you have to touch them, &lt;code&gt;cp -a&lt;/code&gt; to a backup and leave the originals where they are. the doctor fingerprints inodes, not contents.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. the agent was working against me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;symptom.&lt;/strong&gt; I stopped the gateway to run a repair, but it would keep coming back up on its own, get force-killed, and then leave 6GB of half-built plugin copies behind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;cause.&lt;/strong&gt; every gateway start runs "main-session-restart-recovery", which resumes whatever the agent was doing when it got interrupted. which in my case was "execute the remediation plan". so every time I restarted the gateway, the agent would come back up, write another repair script under &lt;code&gt;~/.openclaw/recovery/&lt;/code&gt;, scheduled it with &lt;code&gt;systemd-run&lt;/code&gt;, and then that script sent &lt;code&gt;SIGKILL&lt;/code&gt; to the gateway to get exclusive access to the sqlite files. so essentially I was fighting my own instructions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;how to detect it:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; ~/.openclaw/recovery/
systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; list-units &lt;span class="s1"&gt;'openclaw-*'&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt;     &lt;span class="c"&gt;# transient units the agent created&lt;/span&gt;
journalctl &lt;span class="nt"&gt;--user&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'systemd-run|SIGKILL'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;lesson.&lt;/strong&gt; a resumable agent with shell access is a second operator. before doing manual editing, you should wait for its turn to end (the log says &lt;code&gt;restart recovery terminal&lt;/code&gt;) or tell it in chat to pause. its scripts reached the same TMPDIR conclusion I did, so reading them saved me time.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. hardening: docker ports that ufw never saw
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;finding.&lt;/strong&gt; the original audit flagged 4 ports as reachable on all interfaces: 2 postgres databases, a minio API, and a minio console. &lt;code&gt;ufw&lt;/code&gt; was set to deny all incoming except tailscale, so I had assumed they were covered. I was wrong. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;why they were exposed.&lt;/strong&gt; docker inserts its own iptables rules ahead of ufw when you publish a port, so &lt;code&gt;-p 55432:5432&lt;/code&gt; in a &lt;code&gt;docker run&lt;/code&gt;, or &lt;code&gt;'5433:5432'&lt;/code&gt; in a compose file, is reachable from the internet no matter what ufw's policy says. the &lt;code&gt;DOCKER-USER&lt;/code&gt; chain, which is where you're meant to put your own rules, was empty. one of those databases had been started by hand with an 8-character password.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;fix that survives reboot.&lt;/strong&gt; append to &lt;code&gt;/etc/ufw/after.rules&lt;/code&gt; and &lt;code&gt;after6.rules&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
-A DOCKER-USER -i tailscale0 -j RETURN
-A DOCKER-USER -i eth0 -m conntrack --ctstate NEW -j DROP
-A DOCKER-USER -j RETURN
COMMIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then &lt;code&gt;ufw reload&lt;/code&gt;. replace &lt;code&gt;eth0&lt;/code&gt; with your public interface from &lt;code&gt;ip route show default&lt;/code&gt;. that blocks new inbound connections to any published container port on the public interface while still allowing them over tailscale.&lt;/p&gt;

&lt;p&gt;then fix the source instead of leaving the firewall: bind ports to loopback in compose files (&lt;code&gt;'127.0.0.1:5433:5432'&lt;/code&gt;) and in &lt;code&gt;docker run&lt;/code&gt; (&lt;code&gt;-p 127.0.0.1:55432:5432&lt;/code&gt;), and use long random passwords even for test databases (&lt;code&gt;openssl rand -base64 30&lt;/code&gt;). nothing running on the host noticed the change — not the app, not the tests, not tools over tailscale SSH — because all of them already connect via localhost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;metric.&lt;/strong&gt; ports listening on &lt;code&gt;0.0.0.0&lt;/code&gt; other than SSH: 4 before, 0 after.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. stabilizing the host
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;swap.&lt;/strong&gt; 8GiB box, gateway peaking at 5.6GiB, and no swap at all. I added a 4GiB swapfile with &lt;code&gt;vm.swappiness=10&lt;/code&gt;. under later pressure 3.3GiB got paged out instead of the OOM killer firing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;codex diagnostic logs.&lt;/strong&gt; &lt;code&gt;codex-home/logs_2.sqlite&lt;/code&gt; had grown to 1.2GB of TRACE/DEBUG rows in 10 days. I deleted rows older than 2 days and ran &lt;code&gt;VACUUM&lt;/code&gt; with &lt;code&gt;auto_vacuum=INCREMENTAL&lt;/code&gt;: 1,229MB down to 153MB. then &lt;code&gt;Environment=RUST_LOG=info&lt;/code&gt; in another drop-in to cut the volume at the source. these logs only feed codex's "send feedback" bug reports, so there's nothing to lose. an hourly job now prunes rows older than 7 days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;plugin build leak.&lt;/strong&gt; every &lt;code&gt;openclaw doctor&lt;/code&gt; run, even a read-only one, left 3 build directories of 342MB each behind. over 1 afternoon that was 30 directories and 11GB. section 9 is how cleaning this up went wrong before it went right.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. model choice
&lt;/h2&gt;

&lt;p&gt;the agent was on GPT-5.6 Sol with Terra and Luna as fallbacks. &lt;code&gt;openclaw models list --provider openai&lt;/code&gt; showed &lt;code&gt;openai/gpt-6-astra&lt;/code&gt; (released 2026-09-03) already in the catalog and already authenticated, so the switch was 4 steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;add the model to &lt;code&gt;agents.defaults.models&lt;/code&gt; and &lt;code&gt;agents.defaults.modelPolicy.allow&lt;/code&gt;, because the allowlist blocks overrides otherwise.&lt;/li&gt;
&lt;li&gt;test before committing: &lt;code&gt;openclaw agent --agent main --session-key agent:main:model-test --model openai/gpt-6-astra -m "Reply with exactly: OK"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;set &lt;code&gt;agents.defaults.model.primary&lt;/code&gt; and reorder the fallbacks. the gateway hot-reloads config so no restart.&lt;/li&gt;
&lt;li&gt;delete the test session: &lt;code&gt;openclaw sessions delete --agent main agent:main:model-test --yes&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I kept Luna as &lt;code&gt;utilityModel&lt;/code&gt;, &lt;code&gt;heartbeat.model&lt;/code&gt;, and &lt;code&gt;subagents.model&lt;/code&gt; so my background work can remain cheap. test turns came back in about 6 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. where the quota was actually going
&lt;/h2&gt;

&lt;p&gt;quota on a codex subscription is measured in 5-hour and weekly windows, and &lt;code&gt;openclaw gateway usage-cost&lt;/code&gt; reports $0 because nothing is metered per token. so the real signal is &lt;code&gt;task_runs&lt;/code&gt; in the state database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
import sqlite3, os, time
c = sqlite3.connect('file:' + os.path.expanduser('~/.openclaw/state/openclaw.sqlite') + '?mode=ro', uri=True)
since = int((time.time() - 7*86400) * 1000)
for r in c.execute("select label, status, count(*) from task_runs where created_at &amp;gt; ? group by label, status order by 3 desc limit 10", (since,)):
    print(r)
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;1 week looked like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;automation&lt;/th&gt;
&lt;th&gt;LLM runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;my-menulet staged implementation watchdog&lt;/td&gt;
&lt;td&gt;1,209&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;guides-gallery-field-watchdog&lt;/td&gt;
&lt;td&gt;106&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;guides-testflight-build-16-monitor&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;actual conversation with me&lt;/td&gt;
&lt;td&gt;a few dozen&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;3 cron jobs the agent had created to "monitor" things had been polling with the full model every few minutes for days, reporting "still blocked" each time. roughly 95% of the week's turns went to that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;also found.&lt;/strong&gt; compaction of the main telegram session was timing out at 180 seconds and getting cancelled, so every turn was sending about 114k tokens of history. so I raised &lt;code&gt;agents.defaults.compaction.timeoutSeconds&lt;/code&gt; to 600. one note if you're on a codex-backed agent: don't set &lt;code&gt;compaction.model&lt;/code&gt;. codex owns compaction natively and the doctor strips that key back out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;rules now in the agent's AGENTS.md:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;monitoring is a shell job, not an LLM job. schedule a shell check; wake the model only when the result changes.&lt;/li&gt;
&lt;li&gt;every cron job gets a timeout and an end condition. minimum polling interval 15 minutes. cheap model for status summaries.&lt;/li&gt;
&lt;li&gt;suggest &lt;code&gt;/new&lt;/code&gt; to the user when a project task wraps up. context past ~50k tokens per turn is waste.&lt;/li&gt;
&lt;li&gt;1 git checkout per active branch; remove worktrees when the branch merges; use the package manager the repo's committed lockfile declares.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;heartbeats.&lt;/strong&gt; the reader agent's heartbeat had been running every 30 minutes for no purpose. I set &lt;code&gt;heartbeat.every: "0m"&lt;/code&gt;, which disables the cadence, plus &lt;code&gt;activeHours&lt;/code&gt; 08:00 to 22:00 and &lt;code&gt;lightContext: true&lt;/code&gt; so that re-enabling it later stays bounded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;disk hygiene, same audit.&lt;/strong&gt; the workspace was 74GB: 31 worktrees plus 24 clones of one repo, and 33GB of duplicated &lt;code&gt;node_modules&lt;/code&gt;. working out which were safe to remove:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;projects/tro-net &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git fetch origin
git branch &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;--merged&lt;/span&gt; origin/main             &lt;span class="c"&gt;# merged branches&lt;/span&gt;
git worktree list                              &lt;span class="c"&gt;# all checkouts, including ones under projects/&lt;/span&gt;
git &lt;span class="nt"&gt;-C&lt;/span&gt; &amp;lt;checkout&amp;gt; status &lt;span class="nt"&gt;--porcelain&lt;/span&gt;           &lt;span class="c"&gt;# must be empty (or only generated files)&lt;/span&gt;
git &lt;span class="nt"&gt;-C&lt;/span&gt; &amp;lt;checkout&amp;gt; log origin/main..HEAD        &lt;span class="c"&gt;# must be empty (no unpushed commits)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I removed 7 merged worktrees and 2 merged checkouts with &lt;code&gt;git worktree remove --force&lt;/code&gt;, then &lt;code&gt;git worktree prune&lt;/code&gt;. that freed up 4GB, not the 9GB &lt;code&gt;du&lt;/code&gt; had suggested, because pnpm had hardlinked files into a shared store and &lt;code&gt;du&lt;/code&gt; was double-counting them. the other thing this exposed: the repo was actually an npm project — fresh &lt;code&gt;package-lock.json&lt;/code&gt;, 4-month-old &lt;code&gt;pnpm-lock.yaml&lt;/code&gt; — and the agent had been generating pnpm lockfiles inside it. don't infer the package manager from which lockfiles exist. check which one is committed and current.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. what I broke myself, and the fix
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;symptom, 2 hours after everything was working.&lt;/strong&gt; every telegram message came back with "Something went wrong while processing your request", and &lt;code&gt;/new&lt;/code&gt; didn't help.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0elbiambbpv9j687z9cr.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0elbiambbpv9j687z9cr.PNG" alt="telegram bot: Something went wrong while processing your request" width="800" height="1739"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;cause.&lt;/strong&gt; my hourly cleanup timer removed plugin build directories older than 60 minutes. the running gateway keeps resolving its loaded plugins out of the build directories it created at startup, and it holds no open file handle on them, so &lt;code&gt;lsof&lt;/code&gt; shows you nothing. when the timer removed them, every turn failed with &lt;code&gt;ENOENT ... openclaw-plugin-build-XXXX/.../@openclaw/codex/package.json&lt;/code&gt;, and all 3 fallback models failed identically because they share the plugin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;fix.&lt;/strong&gt; &lt;code&gt;systemctl --user restart openclaw-gateway&lt;/code&gt; rebuilt the directories immediately. the cleanup script now reads the gateway's start time (&lt;code&gt;ps -o etimes= -p &amp;lt;gateway pid&amp;gt;&lt;/code&gt;) and only deletes build directories with an mtime older than that. anything created during the current gateway's lifetime is never touched, and after a restart the previous gateway's directories become deletable. I tested it by running the script with a 0-minute threshold — live dirs survived — and against a fake 3-hour-old directory, which it removed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;lesson.&lt;/strong&gt; "no process has it open" is not the same as "nothing depends on it". before you automate deletion of anything a long-running service created, restart the service and see whether it recreates it.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. backups, the part that was missing entirely
&lt;/h2&gt;

&lt;p&gt;there were 0 backup runs recorded and the workspace git repo had 0 commits, which meant the agent's identity, instructions, and memory notes were all unversioned. I fixed this with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw backup git init &lt;span class="nt"&gt;--repository&lt;/span&gt; ~/openclaw-backups-git
openclaw backup git create &lt;span class="nt"&gt;--repository&lt;/span&gt; ~/openclaw-backups-git &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--exclude-secrets&lt;/span&gt;
openclaw backup &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--repository&lt;/span&gt; ~/openclaw-backups-git &lt;span class="nt"&gt;--every&lt;/span&gt; 24h &lt;span class="nt"&gt;--exclude-secrets&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/.openclaw/workspace &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'projects/\n.worktrees/\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .gitignore &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git add &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Initial commit of workspace continuity files"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;first backup: 301MB. it runs daily as an openclaw automation now.&lt;/p&gt;

&lt;h2&gt;
  
  
  final state and metrics
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;measure&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;openclaw version&lt;/td&gt;
&lt;td&gt;2026.7.1-2&lt;/td&gt;
&lt;td&gt;2026.9.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;primary model&lt;/td&gt;
&lt;td&gt;GPT-5.6 Sol&lt;/td&gt;
&lt;td&gt;GPT-6 Astra&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;swap&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;4GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;container ports on public interface&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;codex log database&lt;/td&gt;
&lt;td&gt;1,229MB&lt;/td&gt;
&lt;td&gt;153MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;enabled plugins&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;td&gt;12 (allowlist)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unattended LLM runs per week&lt;/td&gt;
&lt;td&gt;~1,340&lt;/td&gt;
&lt;td&gt;a nightly memory job and a weekly skill review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;workspace continuity files under version control&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes, plus daily DB backups&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;disk used&lt;/td&gt;
&lt;td&gt;111GB (peaked at 122GB during repair)&lt;/td&gt;
&lt;td&gt;107GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  the short list of things I would tell anyone running this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;keep an out-of-band way in since the agent cannot report a failure that kills its own channel.&lt;/li&gt;
&lt;li&gt;sorta unrelated to the overall setup, but good to remember: docker publishes ports around ufw. bind to &lt;code&gt;127.0.0.1&lt;/code&gt; and put drop rules in &lt;code&gt;DOCKER-USER&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;check whether &lt;code&gt;/tmp&lt;/code&gt; is RAM. if it is, point build tooling at &lt;code&gt;/var/tmp&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;never move openclaw session or state files. the doctor fingerprints inodes.&lt;/li&gt;
&lt;li&gt;after any gateway restart, expect the agent to resume its last task and act as a second operator.&lt;/li&gt;
&lt;li&gt;on a subscription, "cost" is quota. query &lt;code&gt;task_runs&lt;/code&gt;, not billing. LLM-based watchdog crons are the most expensive thing you can accidentally create.&lt;/li&gt;
&lt;li&gt;give every automation a timeout. give every long conversation an ending (&lt;code&gt;/new&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;before automating deletion of anything a service created, restart the service and see what it recreates.&lt;/li&gt;
&lt;li&gt;version the agent's identity and memory files and schedule database backups before you need them.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>agents</category>
      <category>devops</category>
      <category>docker</category>
      <category>linux</category>
    </item>
  </channel>
</rss>
