claude -p endet mit Exit-Code 0, wenn die Anmeldung tot ist

Christian Drexler

Ich habe ein paar Jobs, die Claude Code nach Zeitplan aufrufen. Ein Wrapper-Skript startet claude -p mit einem Prompt, nimmt, was zurückkommt, und macht etwas damit, zum Beispiel einen Entwurf in einen Ordner schreiben. Jeder Lauf hinterlässt eine Zeile im Log mit dem Exit-Code. Elf Tage lang stand in jeder Zeile, der Lauf sei in Ordnung gewesen.

In denselben elf Tagen war kein Entwurf entstanden. Aufgefallen ist es mir, weil die Ergebnisse fehlten, nicht weil mir irgendetwas Bescheid gesagt hätte.

Was passiert war

Der Rechner, auf dem diese Jobs laufen, steht ohne Bildschirm in einem Schrank. Claude Code war dort einmal angemeldet worden, über den normalen Weg im Browser, und die Anmeldung lebte seitdem in dieser Benutzersitzung. Irgendwann ließ sie sich nicht mehr erneuern.

Von da an tat jeder geplante Lauf das hier:

$ claude -p "Write today's draft ..."
Failed to authenticate: OAuth session expired and could not be refreshed
$ echo $?
0

Eine Zeile Text und Exit-Code null. Aus Sicht des Kommandozeilenwerkzeugs war nichts abgestürzt. Es war gestartet, hatte festgestellt, dass es mit niemandem reden kann, hatte das höflich gesagt und sich beendet.

Mein Wrapper prüfte den Exit-Code und sonst nichts. Also protokollierte er Erfolg, der Scheduler protokollierte Erfolg, und das Log war eine grüne Spalte, die nichts bedeutete.

Die Ausgabe beurteilen, nicht den Exit-Code

Die Lösung ist nicht raffiniert. Vor dem eigentlichen Prompt stellt der Wrapper jetzt eine Frage mit bekannter Antwort und sieht sich den Text an, der zurückkommt.

probe=$(claude -p "Reply with the single word OK" --model haiku 2>&1)

if ! printf '%s' "$probe" | grep -q "OK" ||
     printf '%s' "$probe" | grep -qi "authenticate\|not logged in"; then
  echo "claude probe failed: $probe" >&2
  notify "Claude job cannot authenticate on $(hostname)"
  exit 1
fi

Die Probe kostet den Bruchteil eines Cents und zwei Sekunden. Kommt das Wort nicht zurück, bricht der Wrapper mit einem echten Fehlercode ab und schickt eine Nachricht in den Chat, den ich tatsächlich lese. Für eine Antwort aus einem Wort reicht das kleine Modell.

Der eigentliche Aufruf wird danach genauso behandelt. Was immer der Job produzieren soll, der Wrapper prüft, ob es da ist und eine plausible Größe hat. Eine Entwurfsdatei, die nicht existiert oder nur eine Zeile enthält, die mit „Failed“ beginnt, ist ein gescheiterter Lauf, egal was der Exit-Code behauptet.

out=$(claude -p "$PROMPT" 2>&1)
if [ "${#out}" -lt 400 ] || printf '%s' "$out" | grep -qi "failed to authenticate"; then
  notify "Claude job produced no usable output"
  exit 1
fi

Die Zahl 400 ist willkürlich. Nimm etwas, das bequem unter dem kürzesten echten Ergebnis und weit über jeder Fehlermeldung liegt.

Einem Rechner ohne Bildschirm ein eigenes Token geben

Die zweite Hälfte der Lösung beseitigt die Ursache. Eine Anmeldung, die in einer Desktop-Sitzung lebt, ist die falsche Art Zugang für einen Job, der um vier Uhr morgens läuft. Claude Code kann genau für diesen Fall ein langlebiges Token ausstellen.

Auf einem Rechner, auf dem du normal angemeldet bist:

claude setup-token

Der Befehl gibt ein Token aus. Lege es auf dem Rechner ohne Bildschirm in eine Datei, die nur der Benutzer des Jobs lesen kann, und exportiere es im Wrapper vor dem ersten Aufruf.

# once: paste the token, press Enter, then Ctrl-D
( umask 077; cat > ~/.config/claude/oauth_token )

# in the wrapper
export CLAUDE_CODE_OAUTH_TOKEN="$(cat ~/.config/claude/oauth_token)"

Ist diese Variable gesetzt, hängt der Befehl weder an einer Browser-Anmeldung noch an einem Schlüsselbund. Er verhält sich auch gleich, egal ob der Scheduler den Job startet, du per SSH oder der Rechner nach einem Neustart, bei dem sich niemand am Bildschirm angemeldet hat. Über SSH hatte mir derselbe Rechner „Not logged in“ gemeldet, während die Desktop-Sitzung noch funktionierte. Das war verwirrend, bis ich verstanden hatte, dass die beiden an verschiedenen Stellen nachsehen.

Auch das Token läuft irgendwann ab. Das ist in Ordnung, denn die Probe fängt es jetzt beim ersten gescheiterten Lauf ab und sagt mir Bescheid, und es dauert keine elf Tage mehr, bis ich es merke.

Das Muster dahinter

Dieser Fehler hat eine Form, die man kennen sollte, weil es die teure Sorte ist. Ein Job, der abstürzt, ist billig: Etwas wird rot, und man repariert es. Ein Job, der Erfolg meldet und nichts tut, kostet genau so lange, wie ein Mensch braucht, um das fehlende Ergebnis zu vermissen. Das kann eine Stunde sein oder ein Quartal.

Kommandozeilenwerkzeuge, die mit einem Dienst sprechen, geraten leicht hinein. Das Werkzeug hat funktioniert. Es konnte den Dienst nicht erreichen oder wurde nicht hineingelassen. Es hat das in einem Satz gesagt. Ob dieser Satz mit einem Exit-Code ungleich null kommt, hat jemand an einem Dienstag entschieden, und es kann sich zwischen Versionen ändern.

Für alles Geplante gehe ich deshalb davon aus, dass der Exit-Code mir sagt, ob der Prozess gelaufen ist, und nichts darüber, ob die Arbeit passiert ist. Der Wrapper sucht nach dem, wofür der Job existiert. Eine Datei mit Inhalt, eine Zeile in einer Tabelle, ein Wort in der Antwort. Fehlt das, ist der Lauf gescheitert, und der Job sagt es an einer Stelle, an der ich es sehe.