kingshand

Noticing a worker that has stopped making progress

Where this came from

Source
docs/2026-09-01-stall-detection.md in emgee-labs/kingshand
Mirrored from
commit 64fedfbb, dated 2026-09-01

This page is a mirror. The text below is the document as it stands in the repository; edits belong there, and reach this page the next time the site is built. Nothing is rewritten for the web on the way here, so the headings, tables and code below are the ones the repository holds, and a link that points at another record points at the copy served here. Back to all records, or to the overview for what the tool these records describe actually does.

The document

Date: 2026-09-01 Status: current

What happened

Two failures on the same night, both costing real time, both invisible until the King asked.

A review gate waited an hour for CI that could never arrive. A pipeline run on the kingshand repository reached its ci step and sat there for over an hour. This repository has no CI at all: no workflow files, nothing configured on GitHub, zero check runs on the commit. The gate cannot tell "checks have not started yet" from "checks will never exist", so its wait had no end.

The Hand's own wait was watching the wrong thing. Wait-HerdrAgentSettled asks whether a worker is alive. A worker that handed its work to a background pipeline and returned to its prompt read done immediately, so the wait fired within seconds and reported a completion that had not happened. The same night the opposite also happened: a worker whose work was genuinely finished read working, because stray text was sitting in its input box.

The common thread is that liveness and progress are different questions, and only one of them is the one anybody cares about.

The decisions

CI is established before a worker is promised a wait for it

bin\Ci.psm1 answers one question - can anything report a check on this repository's pull requests

Two signals, and the second one is why this is not a directory test. A .github\workflows directory is the obvious signal and it is insufficient in both directions: emgeelabs-site has no workflow file anywhere in the repository and gets Cloudflare Pages check runs on every commit, while a repository that deleted its last workflow keeps the empty directory. So the stronger signal is the checks GitHub actually reported on recent commits of the default branch, and it is consulted whenever nothing the repository itself configures could report on a pull request. That last clause is the third way a file listing lies: a workflow triggered only by schedule or workflow_dispatch exists, runs, and still never puts a check on a pull request, so its triggers are read rather than its presence counted. It is also the fourth: on a GitHub remote, another provider's config file - a dormant .travis.yml, a .gitlab-ci.yml from a mirror - says nothing about the GitHub pull request the worker will actually open, so GitHub is asked instead of the file being believed. Where there is no GitHub to ask, that same file stays a positive signal, because discarding it would be guessing in the expensive direction. A workflow whose on: block cannot be read is kept for that reason too. Both were verified against the three real cases on this machine: kingshand answers no-ci, emgeelabs-site answers has-ci from check runs alone, and a repository that cannot be reached answers unknown with the HTTP error in the detail.

The third answer is the point. unknown is the refusal to guess when the question could not be settled - no gh, a remote that is not GitHub, an unauthenticated machine, a network that did not answer. Nothing in that module converts a failed lookup into either answer, because both wrong answers are expensive: a false no-ci throws away a real green check, and a false has-ci restores the hour-long wait. unknown takes the same terminating brief line as no-ci, because under uncertainty stopping at the pull request loses at most a wait for a check the user can see on the forge anyway. The shared line says checks may not report rather than that they are not expected to: the instruction is what ends the wait, and a line that asserted the absence as fact would have a worker report a repository as CI-less on the strength of an expired token.

The progress signal is the worker's own screen, normalised

Three signals were available and the screen was chosen.

The screen won because the Hand waits on investigations, audits and plain edits as well as pipeline runs, and a watcher that only understands pipelines is blind to every other kind of work. It also subsumes the pipeline signal in practice: a review gate prints its own step transitions into the worker's terminal, so a step advancing is a screen change, with nothing here having to know that a pipeline exists. That also sidesteps the trap that no-mistakes axi status with no --run returns the most recent run in the repository rather than the one being watched - a watcher started before its own run registered once read a different, already completed run and reported success immediately. Not knowing about runs at all cannot get the run id wrong.

Normalisation is what makes the screen usable. Claude Code repaints an elapsed timer and a token counter every second while it works, so the raw screen is never twice the same and a naive hash would report a worker frozen for an hour as making steady progress. Only those volatile shapes are removed

Twenty minutes, and it is a parameter

A review pass on this project has legitimately taken 38 minutes, though it printed progress throughout. Under about fifteen minutes, slow steps start reporting as stalls; an hour is plainly too long, since that is roughly what the incident cost. Twenty minutes is the default on Wait-HerdrAgentProgress and -StallMinutes overrides it per call.

What the wake can and cannot promise

The stall half answers the second failure: a worker that has stopped advancing is reported whatever state word it carries. The first failure has no watcher's answer at all. A worker that has genuinely finished and one that handed its work to a background pipeline and went quiet are indistinguishable from outside - both read done within seconds, and no amount of screen reading separates them, because both screens show a prompt.

So the wake does not claim to. It carries the worker's final screen out with the settled answer, and muster says in as many words that state=done is a state word rather than a delivery: read what the screen last said, then check report.md. Reporting the screen from before the wait would have been worse than reporting none, so an unreadable final screen answers signalReadable = $false rather than falling back to a stale line.

Reporting is the whole deliverable

Wait-HerdrAgentProgress never recovers anything: no steer, no answered prompt, no relaunch, no stop. It returns a stall with the evidence needed to act - how long, what the worker was last seen doing, whether its screen could be read at all - and rally owns the response. A wrong automatic action on a stalled worker is worse than a late human one.

What must not be undone

Revisit when