Generated by ARK (Agent Reliability Kit) · 2026-07-28 · Target: OpenClaw 2026.5.28 (current main affected), Debian container on Kubernetes, Node 24 · Original issue
| ID | Severity | Symptom |
|---|---|---|
| F-1 | Critical | Usage-cost refresh lock orphaned by an unclean gateway exit is honoured forever when the restarted gateway reoccupies the same PID (e.g. PID 9 under tini). Cache frozen for 12 days. |
| F-2 | Cascading | Frozen cache forces every consumer to recompute from transcripts; recompute set grows ~288 transcripts/day without bound (224 MB re-parsed per call at time of report). |
| F-3 | Cascading | Growing recompute pushes isolated-cron setup past the fixed 60s watchdog — failure rate climbed 0% → 2% → 20.8% → 37.9% over 13 days, masquerading as the unrelated symptom in #82662. |
The lock file records {pid, startedAt, token}, but staleness is judged with isProcessRunning(lock.pid) alone. That test answers "is some process alive at this PID?" — not "is the process that wrote this lock alive?". In any environment where PIDs are stable across restarts (containers under tini, minimal PID namespaces, supervisors that respawn into low PIDs), the two questions have different answers, and the code conflates them:
lock payload owner : pid=9 startedAt=2026-07-15 10:35:21 CST
ACTUAL pid 9 start : 2026-07-24 12:58:39 CST # container restarted; PID reused
delta : 9.10 days # lock still honoured
The bitter irony: parseRefreshLock() already parses and validates startedAt, and the writer already records it. The one comparison that would detect reuse — recorded startedAt vs. actual process start time — is simply never made. This is a classic identity-vs-address bug: a PID is an address, not an identity.
The frozen cache never throws. It degrades every consumer into a full transcript recompute whose cost grows daily, until an unrelated fixed timeout (the 60s cron setup watchdog) starts firing. The error message points at cron, not at the lock — which is why the reporter's failure-rate table is such a valuable forensic artifact: the very first failure is the next scheduled run after the lock appeared.
// staleness check: the lock's startedAt must be >= the current
// occupant's process start time, else the writer is a dead incarnation
function isLockLive(lock) {
if (!isProcessRunning(lock.pid)) return false;
const procStart = getProcessStartTime(lock.pid); // /proc/<pid>/stat field 22
if (procStart > lock.startedAt) return false; // PID reused by a newer process
return true;
}
Alternative shapes, strongest first:
flock — lock dies with the process, even on SIGKILL. Zero staleness logic needed; costs some portability care.startedAt exceeds N× the worst observed refresh duration, force-release regardless. Belt and braces.PR #114254 (by the issue reporter) targets exactly this ownership-comparison route and is the canonical candidate per ClawSweeper's review. The fix shape matches what the on-disk lock format already records — no new state needed.
rm .../.usage-cost-cache.json.lock — reporter confirmed refresh resumes within secondsstartedAt predates container boot timeThis failure chain belongs to classes ARK guards against in agent systems:
| Finding | Failure class | ARK guard |
|---|---|---|
| F-1 | Ownership proven by address, not identity | IdempotencyGuard — bind every lock/handle to an incarnation token, not a reusable ID |
| F-2 | Silent unbounded work growth | OutputValidator — verify cache freshness after mutation; stale reads are failures, not fallbacks |
| F-3 | Fixed timeout meets growing workload | CircuitBreaker — surface latency-budget breaches at the source, before a downstream watchdog misattributes them |
pip install ark-trust # Python
npm install @feilunxitong/arkit # TypeScript
Idempotency guards · retry policy · logging — free 30-second check.
Run the free ARK diagnostic →