|null $seedAlive Lebenszeichen, das vorher liegt * @return array> je ein Lebenszeichen pro Lauf */ function runBlockedAgent(int $times, ?array $seedAlive = null): array { $dir = storage_path('app/deploy'); File::ensureDirectoryExists($dir); File::delete(File::glob($dir.'/.alive-*')); File::delete($dir.'/agent-alive.json'); File::delete($dir.'/.held'); if ($seedAlive !== null) { File::put($dir.'/agent-alive.json', json_encode($seedAlive)); } $runs = ''; for ($i = 1; $i <= $times; $i++) { $runs .= "bash deploy/update-agent.sh >/dev/null 2>&1\n"; $runs .= "cp storage/app/deploy/agent-alive.json storage/app/deploy/.alive-{$i}\n"; } // Die Sperre wird nachweislich gehalten, bevor der Agent startet: der // Halter legt erst die Marke an, dann wird auf sie gewartet. Ohne diesen // Nachweis liefe der Agent bei einem Fehlschlag von flock in seinen // NORMALEN Weg — mit Abruf der Gegenstelle und allem, was daran hängt — // und der Test hätte still etwas ganz anderes gemessen. $result = Process::path(base_path()) ->timeout(60) ->run(<</dev/null || true BASH); expect($result->successful())->toBeTrue($result->errorOutput()); return array_map( fn (int $i) => json_decode(File::get($dir."/.alive-{$i}"), true), range(1, $times) ); } afterEach(function () { File::deleteDirectory(storage_path('app/deploy')); }); it('counts a single skipped run as one, not as a blockade', function () { // Der gemessene Fall: neun Sekunden Überholen zwischen Zeitgeber und // Wächter. Betrieb, kein Fehler. [$first] = runBlockedAgent(1); expect($first['state'])->toBe('blocked') ->and($first['skips'])->toBe(1); }); it('keeps counting while the same blockade holds', function () { [$first, $second] = runBlockedAgent(2); expect($first['skips'])->toBe(1) ->and($second['skips'])->toBe(2) // Und `since` bleibt stehen — sonst stünde dort immer „seit einer // Minute" und eine Stunde Stillstand sähe aus wie ein Überholen. ->and($second['since'])->toBe($first['since']); }); it('starts the count over after a run that got the lock', function () { // Ein Lebenszeichen aus einem Lauf, der gearbeitet hat, beendet die Serie. // Ohne diesen Schnitt liefe der Zähler über eine gesunde Zwischenzeit // hinweg weiter und meldete eine Blockade, die längst vorbei war. [$first] = runBlockedAgent(1, [ 'at' => '2026-08-04T05:00:00Z', 'state' => 'running', 'since' => '', 'held_by' => '', 'skips' => 0, ]); expect($first['state'])->toBe('blocked') ->and($first['skips'])->toBe(1); }); it('says zero skipped runs while it is working', function () { // Der Zustand, in dem der Agent die Sperre HAT. Ohne die ausdrückliche // Null bliebe die Zahl des letzten blockierten Laufs im Lebenszeichen // stehen, und die Konsole läse sie als fortdauernde Blockade. // // Hier läuft der Agent auf der freien Sperre — also durch seinen normalen // Weg. `git` und `docker` liegen dafür als Attrappen im PATH, die sofort // scheitern: dieser Test prüft, was der Agent ins Lebenszeichen schreibt, // und hat keinen Grund, dafür die Gegenstelle abzurufen oder den // Docker-Daemon anzufassen. Beide Fehlschläge sind Wege, die der Agent // ohnehin abfängt. $dir = storage_path('app/deploy'); File::ensureDirectoryExists($dir); File::delete($dir.'/agent-alive.json'); // Der Ausgang des Laufs wird NICHT geprüft: mit einem `git`, das immer // scheitert, kann der Agent nicht bis zum Ende kommen, und ans Ende zu // kommen ist hier auch nicht die Frage. Das Lebenszeichen steht als // ERSTES, noch vor allem, was scheitern kann — genau deshalb gibt es die // Datei überhaupt. Process::path(base_path())->timeout(60)->run(<<<'BASH' stub="$(mktemp -d)" printf '#!/bin/sh\nexit 1\n' > "$stub/git" printf '#!/bin/sh\nexit 1\n' > "$stub/docker" chmod +x "$stub/git" "$stub/docker" PATH="$stub:$PATH" bash deploy/update-agent.sh >/dev/null 2>&1 rm -rf "$stub" BASH); $alive = json_decode(File::get($dir.'/agent-alive.json'), true); expect($alive['state'])->toBe('running') ->and($alive['skips'])->toBe(0); });