A Running Status Doesn't Mean A Claude Code Session Isn't Waiting On You

Published

I built a check that tells me when my automated Claude Code pipeline has gone silent. It watches for the one failure mode that matters most: the session died and nothing is running anymore. That check works. The gap I actually hit this week is a different one entirely, and it has nothing to do with the session being dead. Two separate pipelines I run, one for each of two different sites, both ran into the same wall this week. A blog post built cleanly, pushed cleanly, and then the deploy that was supposed to put it live never actually triggered. Both sessions did exactly what they were supposed to do. They noticed the problem, logged it clearly, and stopped instead of guessing or retrying blindly into a dead end. Nobody noticed either one had stopped, because "stopped cleanly and logged why" looks identical from the outside to "working fine." That is a third state my dead man's switch was never built to catch.

Three States: Dead, Working, Waiting On A Decision

A session watching for a human decision needs to track three states, not two: dead, working, and blocked waiting on a human. Most monitoring, including the check I already built, only distinguishes the first two.

Dead means nothing ran when something should have. Working means the last cycle completed and logged normally.

Blocked means the session did real work, hit something only a human can resolve, a dashboard click it is not allowed to make, a credential it does not have, a judgment call it should not make alone, and stopped there on purpose.

That third state is the pipeline behaving correctly, not failing. It just produces output that reads exactly like routine success.

Why A Liveness Check Can't Catch It

My existing check only asks one question: how long since the last successful cycle, and does that gap exceed what the schedule allows.

Both of my pipelines run on session-native cron scheduling instead of the OS task scheduler, which is a separate reason a session can disappear quietly. That is not the failure this week, though.

A blocked session passes the liveness check easily, because it did run, and it did log something, right on schedule.

The gap check I wrote for silent failures reads the content log and looks for the absence of a recent entry. A blocked session leaves a perfectly fresh entry instead.

That fresh entry is present and misleading at the same time. Catching it needs a different signal entirely.

What Actually Happened This Week

Both pipelines hit the identical root cause: a blog deploy that built and pushed without error but never went live on the hosting side.

Both sessions checked the live URL, confirmed it was still returning old content, and stopped rather than posting a dead link to a draft nobody had reviewed yet.

Each one wrote a clear, specific log entry naming exactly what was wrong and exactly what a human needed to do next. That is the correct behavior.

The actual gap was that a clear log entry sitting among dozens of routine ones does not announce itself. Finding it required already knowing to go look, which defeats most of the point of automating the check in the first place.

The Fix Is A Marker, Not A Smarter Dashboard

The fix is a one-line convention: every log entry that represents a stop-for-a-human moment gets a distinct, greppable marker at the start of it, separate from a routine status note.

Nothing about a dashboard, a model inferring intent from log text, or a second liveness check needs to change for this to work.

I write a plain text tag, `NEEDS_HUMAN:`, as the first thing in the note whenever a cycle stops specifically because it needs a decision only I can make. A routine status update never gets that tag.

Scanning for the tag across every content log I keep, instead of reading every log in full, becomes a different, much faster check.

import re
from pathlib import Path

LOGS = [
    Path("openratelab/socials/content-log.md"),
    Path("uros-builds/socials/content-log.md"),
]

def find_needs_human(logs):
    hits = []
    for log_path in logs:
        with open(log_path, encoding="utf-8") as f:
            for line in f:
                if "NEEDS_HUMAN:" in line:
                    date = line.split("|")[1].strip()
                    hits.append((log_path.parts[0], date, line.strip()))
    return hits

That is the entire technique. It does not parse log structure, infer state, or call any model. It greps for a tag I agreed to write consistently, across both pipelines, and returns exactly the rows that need me, nothing else.

if __name__ == "__main__":
    hits = find_needs_human(LOGS)
    if hits:
        for source, date, line in hits:
            print(f"[{source}] {date}: {line}")
    else:
        print("Nothing needs you right now.")

Running that takes less time than reading either log end to end, and it answers the one question that actually matters when I check in on either pipeline: does anything need me right now, specifically, as opposed to is everything fine in general.

What This Doesn't Fix

This still requires actually running the scan, or scheduling it, which means it inherits the same problem my dead man's switch has: a check that only helps if something remembers to run it.

It also depends entirely on every stop-for-a-human moment actually using the tag. Forget it once, in a hurry, writing a normal-looking note instead, and that entry goes right back to being invisible.

It does not resolve anything on its own either. Finding a `NEEDS_HUMAN:` row still means going to read the actual log entry and doing whatever it says, a manual deploy trigger, a credential check, a judgment call.

The scan makes the signal findable in seconds instead of buried in scrollback. It does not make the underlying wait go away, and it should not.

Some decisions genuinely need a person. The point was never to remove that, just to stop losing track of when one is actually pending.

Running more than one unattended pipeline and need to know when one actually needs you?

I build this kind of thing properly the first time, including the markers and checks that catch the difference between a session that's dead and one that's just waiting on a decision.