renfield-mcp-scanner
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
renfield-mcp-scanner
A USB document scanner as an ingest producer for Renfield: scan a stack, correct it, route it to exactly one instance, push it through the existing folder-ingest seam. Dedup, tier, PDF-split, OCR and filing are all unchanged — this server sits in front of them.
Design: renfield/docs/design/scanner-ingest.md.
The invariant
A scan enters exactly one instance, and only after its destination is settled. An unrouted scan never transits any instance's database.
Instances are separate trust boundaries, and ingest is irreversible in practice: within seconds a document has become chunks, embeddings, extracted facts and a filed copy. So there is no default target. A scan that cannot be routed waits in staging on this host for a human.
Related MCP server: @parserelay/mcp
Targets are 1..n, and they are configuration
SCANNER_TARGETS_YAML points at the registry. Adding a target is a config
change, never a code change. n = 1 short-circuits the whole routing layer —
no classifier, no separator sheets, no review queue.
No tokens live in the registry: a target names the env var holding its token
(token_env), resolved at push time.
Status
Phases 1 and 2. Declared-intent routing, the n = 1 short-circuit, real hardware
status, durable staging, retry — and barcode separator sheets: a stack is
split at each sheet and each segment routed by the sheet that opened it.
Phase 3 as well: content classification behind a confidence gate, with a human
review floor beneath it (route_scan) and an append-only decision log
(routing_audit).
The classifier mirrors Renfield's own services/simba_classify.py — one
strict-JSON call, the answer validated against an ALLOWLIST of configured target
ids, best-effort throughout — rather than sharing its code, which lives in the
backend. One difference governs everything: simba_classify prefills a picker and
a human corrects it, while this one files a document across a trust boundary
with nobody looking. So the threshold is far stricter (0.95), and every
ambiguity — unreachable model, unparseable answer, invented id, low confidence —
resolves to "undecided", never to a guess.
Measured against a real model: a clearly company-addressed letter scored 0.90 and still went to the review queue. That is the intended cost, not a defect.
Set SCANNER_CLASSIFIER_URL / SCANNER_CLASSIFIER_MODEL to enable it; empty
disables L3 and an undecided scan simply waits for a human.
Scan jobs. scan_document starts a job and returns {job_id, status: running} at once; the scan runs in the background. The scan used to run inside
the tool call, and on 2026-09-14 that went wrong three ways at once. Renfield's
30 s call timeout reported a scan as failed that had been filed. A longer timeout
only let a refresh tear the call down, and the automatic retry then scanned an
empty feeder. And OCR ran on the event loop, freezing the server for 12 s.
How a job ends. When it finishes, the scanner tells the instance that ASKED, by posting
POST /api/scanner/job-event. That is an event with capped backoff, never a poll. The caller is known from its Bearer token.SCANNER_CALLER_TARGET_<CALLER>names that caller's target, and the target's base URL and push token carry the event.🛑 A 404 is final only when it carries the application's marker. The application answers
{"detail": {"reason": "folder_ingest_disabled", …}}when folder ingest is off, and only THAT 404 stops the retries (contract.INGEST_DISABLED_REASON). Every other 404 — a bare body, adetailthat is a string, a foreign reason — is retryable, because the same number comes from the ingress while no pod is ready. Measured on 2026-10-11 during a rollout: two 503 were retried correctly, then a Traefik 404 discarded the completion event for good. That it never reached the application is proven —FOLDER_INGEST_ENABLEDwastrueand the backend log has nojob-eventline for that second. 401/403 stay final: both arise in the application and are unambiguous. 🛑 The marker is NOTreason: "feature_disabled"— that one is generic (two other Renfield routes use it for two different features) and it rides on a 503, i.e. on a retryable answer. A reason that foreign routes use for "not now" must never mean "forever"; a test pins that the scanner refuses it as a marker (review finding B2). Transitional consequence, accepted deliberately: against a Renfield build that does not send the marker yet, a switched-off ingest is retried for the whole retry horizon — see the next bullet for what bounds it.🛑 A blank 404 is retried for
SCANNER_JOB_EVENT_RETRY_HOURSIN TOTAL, not per service start. Retrying is the safe side of the 404 split, but it amplifies:gave_upevents are re-sent on every start with a fresh attempt budget and only expire withSCANNER_STAGING_RETENTION_DAYS, so a wrongbase_urlinSCANNER_CALLER_TARGET_<CALLER>— which answers a blank 404, indistinguishable from a rollout's — would be asked ~300 times per start for up to 30 days, where it used to cost one ERROR line and stop (review finding A1). The bound: the time of the FIRST blank 404 is written into the record (refused_since, wall clock, so it survives a restart), and once the span is exceeded the event settles asgave_up_404— a state that is never re-sent again. The separator is the duration: a blank 404 on a configured address is either a rollout (seconds) or a misconfiguration (forever). Any other answer in between (a 503, a transport error) clears the judgement — whoever answers 503 is reachable, so the address is right. The first blank 404 per job is a WARNING naming the target URL andSCANNER_CALLER_TARGET_*; the rest are INFO, because 300 identical warnings are none (review finding A2).One scan at a time. There is one feeder, so a second request while a scan runs gets the running job's id back.
Restarts. Job records live under
<staging>/jobs. A restart closes cut-off jobs asinterruptedand re-sends events that never arrived.OCR off the event loop. OCR and PDF assembly run via
asyncio.to_thread.Job records off the event loop, atomically. Every read and write of a job record runs in a worker thread. A write goes to a unique temp file, is fsynced, then renamed over the record. Writes run one at a time in call order, and the record is serialized when
saveis called, so a slow earlier write can never overwrite a newer state.Cleanup while running. A finished job's record is dropped once its event has settled and
SCANNER_STAGING_RETENTION_DAYShave passed. The sweep runs whenever a job's event settles, and once at startup. It is not on a timer: the directory only grows when a job finishes, so sweeping then keeps it bounded without waking up on days nobody scans. A record with an open event is never dropped.Bounded delivery after an outage. At most
SCANNER_JOB_EVENT_CONCURRENCY(default 2) events are in flight per target at once. Only the HTTP request holds a slot, not the backoff sleep. The backoff is jittered so open events do not retry in lockstep, and aRetry-Afteron 429/503 is honoured up to the 5-minute backoff cap. The 24 h retry horizon is unchanged.
Ein wartender Stapel fordert eine ENTSCHEIDUNG an
🛑 Gemessen am 2026-10-11: ein Stapel mit reason: scanner_fault
(Document feeder jammed, zwei Seiten, 20 MB) lag sieben Tage im
Zwischenlager. Zwei digitalisierte Seiten eines Briefes, die nie in einer
Instanz ankamen — und gemeldet wurde er allein als Zahl in der Startzeile
(„2 target(s), 1 pending, 0 purged"). Eine Zahl in einer INFO-Zeile liest sich
wie ein Betriebszustand, nicht wie eine offene Entscheidung.
list_pending_scans hat solche Stapel nie verschwiegen — reason, detail
und at standen immer im Eintrag (nachgemessen, bevor etwas geändert wurde).
Gefehlt haben das abgeleitete Alter (age_seconds) und die Unterscheidung
vom vorgesehenen Wartezustand: fault ist wahr für alles, was nicht
unrouted ist. Ein unrouted-Stapel wartet auf einen Menschen; ein
Fehler-Stapel wartet darauf, dass jemand überhaupt von ihm erfährt. Beide
abgeleiteten Felder stehen NACH den Metadaten aus der Datei, damit eine fremde
stage.json das Urteil nicht umschreiben kann.
Beim Start wird je Stapel, der länger als SCANNER_PENDING_WARN_DAYS (Vorgabe:
ein Tag, 0 schaltet ab) liegt, eine Warnung mit Alter, Grund und
Seitenzahl geschrieben. Je Start genau einmal — kein Takt, kein Wecker, sondern
die Gelegenheit, bei der der Zustand ohnehin gelesen wird.
Die Aufbewahrung greift auch für Fehler-Stapel, und zwar ohne Unterschied
nach Grund: SCANNER_STAGING_RETENTION_DAYS ab der letzten Änderung des
Verzeichnisses, also ab dem letzten keep. Ein Test hält das fest. 🛑 Gekehrt
wird beim START des Dienstes — ein Dienst, der monatelang durchläuft, kehrt
nicht aus. Auf dem Bedienrechner ist das in der Praxis kein Zeitraum (er
schläft, startet neu, wird aktualisiert), aber es ist eine Annahme und keine
Zusage.
Separator sheets
generate_separator_sheets renders one printable A4 sheet per configured
target. They are generated from the registry, never by hand — a sheet whose
payload has drifted from the configuration fails silently: the stack scans,
nothing matches, and the documents land unrouted with no obvious cause.
The payload is namespaced and versioned — RFSEP1:<target_id> — because real
documents carry barcodes of their own (a GiroCode on an invoice, a tracking code
on a parcel notice). Encoding a bare label would let any of those masquerade as
a separator and silently split a document in half.
A sheet marks a boundary and a destination with one mark, which is what lets
a mixed stack be split and routed in a single pass. It is also the only routing
layer that survives an unattended scan, since a button press carries no intent.
Requires zbar (brew install zbar) and the barcode extra.
Der blaue Knopf
🛑 AUS als Vorgabe seit 2026-10-26 (SCANNER_BUTTON_WATCH), weil er den
Scanner festsetzt. Auf dem ScanSnap S1500 schließen sich „Knopf jederzeit"
und „verklemmt nie" gegenseitig aus. Drei Messungen, die zusammen keinen
Ausweg lassen:
Messung | Folge |
Der Impuls dauert 200–250 ms und hält nicht | Takt ≤ 200 ms, sonst gehen Drücke verloren |
Jede Abfrage ist ein | der Takt ist Gerätelast, nicht Prozessorlast |
Die IORegistry trägt keine Knopf-Eigenschaft (2026-10-26 nachgesehen) | kein freies Signal, das das Öffnen ersetzen könnte |
Dazu eine Messung gegen die bequeme Erklärung: im verklemmten Zustand steht
CurrentPowerState == MaxPowerState (ioreg, 2026-10-26). Das schließt
USB-Suspend aus — der Bus hält das Gerät bei voller Leistung, es ist also
nicht der Rechner, der es schlafen legt. ⚠️ Ein geräteinternes Standby
schließt es NICHT aus; dafür gäbe es Sensors.power_save aus sane.py, und
das ist im verklemmten Zustand nicht abfragbar — jede Abfrage bräuchte genau
die Öffnung, die scheitert. Die Auto-Abschaltung ist damit unwahrscheinlich
gemacht, nicht widerlegt.
Verschärfend, lange übersehen: der Takt hängt an sensors.page_loaded
(button.py:493), mit der Begründung „ohne Papier kann der Knopf ohnehin
nichts auslösen". Der Entwurf nahm an, Papier liege nur kurz vor einem Scan
ein. Wer den Deckel offen und Papier dauerhaft eingelegt lässt — der
Normalfall beim Eigentümer — bekommt nach dem Code fünf Öffnungen je
Sekunde statt einer. ⚠️ Gefolgert, nicht gemessen: die Episoden, auf die
sich die Zahlen unten stützen, liefen ausdrücklich „NUR read_sensors
(~1/s)", also ohne Papier im Einzug.
⚠️ Die DOSIS ist nicht belegt, und zwei Messungen widersprechen sich. Zehn Abfragen je Sekunde setzten das Gerät nach rund einer halben Stunde fest (≈ 18.000 Öffnungen); eine Abfrage je Sekunde nach rund 14 Minuten. Letzteres ist am Protokoll nachgemessen (2026-10-26): fünf Episoden im Band 842–872 s, Mittel ≈ 863 s, Streuung ≈ 11 s — alle am 2026-10-21.
Das ist in die FALSCHE Richtung: ein reiner Öffnungszähler müsste bei höherem Takt FRÜHER klemmen. Und die enge Streuung über fünf Läufe passt ebenso gut zu einem Uhrwerk (fester Zeitgeber um 14,5 min) wie zu einer Dosis bei konstantem Takt — alle fünf stammen vom selben Tag, also vermutlich vom selben Takt. Diese Zahl unterscheidet die beiden Erklärungen nicht.
🛑 Dass das Abschalten hilft, ist plausibel und NICHT bewiesen. Gilt die Dosis, hilft es vollständig. Gilt das Uhrwerk, verklemmt das Gerät weiter — dann allerdings in einem Zustand, in dem auch ein angeforderter Scan scheitert, und das wäre vorher wie nachher so. Der entscheidende Versuch ist billig: nach einem Aus/Ein mehr als 15 Minuten nichts tun, dann EINEN Scan anfordern. Klappt er, war es die Dosis.
Solange das offen ist, taugt keine Zahl als Grundlage für einen „sicheren" Takt — und genau deshalb ist der Wächter aus und nicht langsamer gestellt.
Der Wächter (server.py:310) ist die einzige periodische Aufgabe, die das
Gerät anfasst; alles andere öffnet nur auf Anforderung. Aus heißt: im
Ruhezustand null Öffnungen. Gescannt wird über einen angeforderten Scan
(Chat, Weboberfläche, scan_document). watcher_health meldet dann
state: "off" mit finding: false — kein Daueralarm, die Abschaltung ist
eine Entscheidung und wird als solche gemeldet.
SCANNER_BUTTON_WATCH=true schaltet ihn wieder an: für Geräte, deren Sensor
den Druck hält, oder um die Dosis sauber auszumessen. Auf diesem Modell
verklemmt er dann wieder.
Alles Folgende beschreibt, wie der Wächter arbeitet, WENN er eingeschaltet ist — die Maschinerie bleibt vollständig erhalten.
Ein Druck am Gerät startet einen Scan — dieselbe Strecke wie scan_document,
nur ohne Agenten. Ein Hardwareknopf sendet kein Ereignis, also wird der
SANE-Sensor gepollt; das ist die einzige verfügbare Bauform, nicht Bequemlichkeit.
Zwei Takte, beide gemessen: ohne Papier im Einzug eine Abfrage je Sekunde (dort kann ohnehin nichts starten), mit Papier alle 200 ms. Der Knopf meldet einen Impuls von rund 200 ms je Druck — er verriegelt nicht und meldet kein Halten, länger drücken bringt also nichts.
🛑 Der Takt ist nach OBEN und nach UNTEN begrenzt. Zehn Abfragen je Sekunde
belegten den Scanner zu 42 % der Zeit und setzten ihn nach einer halben Stunde
fest (SANE fand ihn nicht mehr, erst Aus- und Einschalten half). Die teure
Ressource ist hier das Gerät, nicht der Prozessor. tests/test_button.py hält
beide Grenzen fest.
Ein Knopfdruck trägt keine Absicht: das Ziel bestimmt der Klassifizierer, und
ein Trennblatt sticht ihn aus. Die FERTIGMELDUNG braucht dagegen eine feste
Adresse — ohne SCANNER_CALLER_TARGET_BUTTON läuft der Scan durch, aber niemand
erfährt davon. Der Schalter ist SCANNER_BUTTON_WATCH (Vorgabe: aus, s. oben).
🛑 Der Wächter läuft unter Aufsicht, und sein Ende ist hörbar. Bis zum
2026-10-11 lief er als nackte Task: watch_button fängt alles Vorgesehene ab
(fehlendes Gerät, Sensorfehler, abgelehnter Scan-Start), aber eine
UNVORHERGESEHENE Ausnahme beendete die Aufgabe endgültig — lautlos, weil die
starke Referenz in server.py Pythons Warnung „Task exception was never
retrieved" unterdrückte: die feuert erst beim Aufräumen, und aufgeräumt wurde
nie. Gemessen am Haushalt: nach dem Papierstau vom 2026-10-04 15:24 schrieb der
Wächter sieben Tage keine Zeile, obwohl sein Fehlerpfad gedrosselt eine
Warnung je Minute protokolliert — ein fehlendes Gerät hätte Tausende erzeugt.
Der blaue Knopf war eine Woche tot, und nichts hat es gesagt.
Jetzt startet supervise_button die Schleife nach einem Absturz neu und ein
Abschluss-Rückruf protokolliert jedes Ende der Aufgabe. asyncio.CancelledError
läuft durch: sie ist das geordnete Herunterfahren.
🛑 Die Wartezeit ist ABGELEITET, nicht gewählt:
RESTART_BACKOFF = REDISCOVER_AFTER * ERROR_BACKOFF (15 s), wachsend, gedeckelt
auf 60 s. Grund: ein neu gestarteter Wächter beginnt mit device = None und
ruft sofort find_device. Produktiv ist config.device LEER — SCANNER_DEVICE
setzt niemand —, also läuft dort scanimage -f, die vollständige SANE-Suche,
die den USB-Anschluss belegt (am 2026-10-11 gemessen: 4,3 s je Suche). Eine
Aufsicht, die schneller neu startet als die Bremse, die sich watch_button
selbst setzt, würde genau den Takt erzeugen, der den Scanner am 2026-09-25
festgesetzt hat. Aus „Knopf tot" würde „Gerät fest" — eine Reparatur, die
schlimmer ist als der Fehler. Zwei Tests halten die Grenze UND ihre Ableitung
fest; eine gleich große Zahl wäre Zufall und driftet beim nächsten Umbau.
🛑 scanner_status sagt, ob der Wächter LEBT — und die Antwort hängt an
einer Zeitmarke, nicht an einer Task. Nach dem Ausfall oben ist der Tod des
Wächters laut (eine ERROR-Zeile mit Rückverfolgung), aber er meldet sich nicht:
eine Protokollzeile wartet, bis jemand nachsieht, und am 2026-10-04 hat sieben
Tage niemand nachgesehen. Das Feld button_watcher macht ihn auffindbar — für
eine funktionale MCP-Probe, die das Werkzeug ohnehin aufrufen kann.
Die Falle, die dabei zählt: die Task EXISTIERTE in diesen sieben Tagen. Jede Auskunft, die daraus abgeleitet wäre, hätte „läuft" gemeldet. Also setzt die Schleife bei jedem Durchlauf selbst eine Marke, und nur deren Alter entscheidet.
Die Grenze ist ABGELEITET, Summand für Summand:
LIVENESS_WORST_GAP = RESTART_BACKOFF_MAX + 2 × sane.PROBE_TIMEOUT + ERROR_BACKOFF — die Wartezeit der Aufsicht nach einem Absturz, plus eine
hängende Gerätesuche, plus ein hängender Sensorabruf, plus die Wartezeit am
Ende des Durchlaufs. Darauf LIVENESS_FACTOR (zwei), damit ein einzelner
unvorhergesehener Halt noch keinen Fehlalarm ergibt. Die beiden SANE-Grenzen
sind der Grund und standen in der ersten Fassung nicht in der Rechnung
(Review-Befund L2): ein hängendes scanimage zieht die Lücke über die
Wartezeit der Aufsicht hinaus, ohne dass der Wächter steht. Die Zeitgrenze
steht darum nur EINMAL als Zahl, in sane.PROBE_TIMEOUT.
🛑 Die Marke steht am ENDE jedes Durchlaufs, auch im Sensorfehlerpfad:
sie bedeutet „ein Durchlauf ist VOLLENDET", nicht „die Schleife wurde
betreten". Am Anfang gesetzt las ein Absturzkreis als alive — die Aufsicht
startet neu, der Wächter betritt die Schleife, die Marke ist frisch, und der
Knopf wirkt trotzdem höchstens zeitweise. Im Review vom 2026-10-11
nachgemessen.
Weil ein Kreis auch einzelne Durchläufe vollenden kann, ist die Marke allein
nicht genug: eine FRISCHE Neustartfolge (CRASHLOOP_RESTARTS Neustarts, der
letzte innerhalb der Grenze) meldet state: "crashloop" und ist ein Befund.
Beide Bedingungen sind nötig — die Aufsicht setzt die Folge nur in ihrem
Absturzzweig zurück, ein Dienst mit zwei Abstürzen beim Start trüge sie sonst
für immer.
Ein schlafender oder ausgestöpselter Scanner ist der NORMALFALL auf einem
Arbeitsplatzrechner und darf keinen Alarm erzeugen — darum trennt der Befund
Lebendigkeit und Gerät (device_readable), und finding ist nur bei
dead und crashloop gesetzt. SCANNER_BUTTON_WATCH=false meldet state: "off", kein Befund. Und
der Befund reitet auf der FEHLERANTWORT mit: scanner_status kehrt bei
fehlendem Gerät früh zurück, und genau dann darf die Probe den Wächter nicht
aus den Augen verlieren.
Feld | Bedeutung |
|
|
| Wahr bei |
| Alter der Marke und die Grenze, gegen die verglichen wurde |
| Wanduhrzeit, seit der die aktuelle Schleife läuft (nur Anzeige) |
| Neustarts der Aufsicht insgesamt, und der Zähler aus ihrer Meldung |
| Ob der letzte Sensorabruf gelang — |
🛑 Ein TOTER Wächter ist laut. Ein BLINDER war stumm.
Die Lebendigkeit oben beantwortet „läuft die Schleife?" — nicht „sieht sie etwas?". Bis 2026-10-21 fielen die beiden Fragen auseinander, und die zweite hatte keine Antwort:
read_sensors verwarf den Exit-Status von scanimage. Ein gescheiterter Abruf
ergab raw={}, daraus alle Sensoren False, error_code=0 — und damit
ready is True. Keine Ausnahme. Also: note_device(True), die Marke tickt,
device_readable liest true, errors bleibt 0, device bleibt gesetzt,
find_device läuft nie wieder. Der Wächter meldet „bereit, kein Papier"
und tut das beliebig lange.
Gemessen am Haushalt: der Prozess lief am 2026-10-21 seit neun Tagen ohne
Neustart, und die letzte Zeile irgendeines Scanner-Bestandteils war vom
2026-10-12 11:57. Ein Knopfdruck erzeugte keine Zeile, keinen Auftrag, nichts
im Backend. Der blaue Knopf war wieder eine Woche tot — diesmal mit lebender
Aufsicht, grüner Lebendigkeit und device_readable: true.
Das ist NICHT der Ausfall vom 2026-10-04 (Task gestorben, Aufsicht fehlte); es ist sein Gegenstück, und #5/#6 greifen dagegen nicht. Was jetzt gilt:
read_sensorswirft auf zwei getrennten Bahnen — Exit-Status ≠ 0 (Backend-Fehler) und leeres Ergebnis bei Status 0 (Regex-/Formatfehler). Je ein eigener Test: deckte eine die andere mit ab, wäre hinterher nicht erkennbar, welche gefeuert hat.Sensors.readyist bei leeremrawFalse— für jede Herkunft eines Satzes, nicht nur fürread_sensors.Damit wird
device_readablezum ersten Mal wahrheitsfähig, und der Fehlerpfad greift überhaupt.
🛑 Was das NICHT behebt. Der Fehlerpfad kennt zwei Fälle, und sie verhalten sich entgegengesetzt:
Fall | was scheitert |
| wirkt |
A |
| bleibt | nein, |
B |
| gesetzt | ja |
Die acht Episoden vom 2026-10-11 waren alle Fall A (462 Warnzeilen, alle
mit der Meldung aus find_device): gemessen 4956 Versuche über 15,3 h, 11,11 s
je Versuch, und Suche (6,17 s) + ERROR_BACKOFF (5,0 s) = 11,17 s — die
Vollaufzählung steckte in jedem Versuch, und kein Zähler ändert daran
etwas. Fall B war in 33 Tagen nie aufgetreten, weil read_sensors nicht laut
scheitern KONNTE. Die Rationierung (since_rediscover, zurückgesetzt beim
Auslösen) gehört deshalb hierher, aber aus dem umgekehrten Grund: die
Fehlschlag-Erkennung macht Fall B zum ersten Mal erreichbar, und ohne sie
entstünde damit ein NEUER Dauerzustand mit Suche in jedem Takt. Ein eigener
Test nagelt die Gegenbehauptung für Fall A fest (M Takte → M Suchen), damit
„der Riegel ist repariert" nicht später für seine Behebung gehalten wird.
Die Proben sind serialisiert — und wie weit das reicht
Fünf Aufrufer lösen find_device unabhängig aus (scan.py, button.py,
tools.py dreimal), und config.device ist produktiv LEER, also sucht jeder
von ihnen voll. Zwei davon laufen regelmäßig gegeneinander, und das erklärt den
Befund „scanner_status scheitert, während der Wächter im selben Prozess
erfolgreich liest": nicht zwei Sichten auf einen Zustand, sondern zwei Öffner,
von denen einer verliert. Ein Zusammenstoß hat jetzt mit
DeviceContendedError eine eigene Klasse — sonst sieht er in der Auswertung
wie ein Verklemmen aus.
🛑 Drei Grenzen, damit niemand mehr erwartet als da ist:
Nur die Proben, nicht der Scan. Ein Schloss über
scan_batchwürde bis zu 1800 s gehalten,LIVENESS_STALE_AFTERliegt bei 370 s — die Aufsicht erklärte den Knopf bei JEDEM langen Scan für tot. Probe gegen Scan zu serialisieren braucht eine Änderung am Wächter (Takt überspringen statt anstehen) und ist nicht gebaut.Reichweite: ein Prozess, eine Ereignisschleife. Das Schloss liegt je Schleife (ein
asyncio.Lock()auf Modulebene bindet sich an die erste und wirft in jeder weiteren — im Dienst unsichtbar, im zweiten Test sofort). Je ein Aufrufer in einem eigenen Thread bekäme sein eigenes Schloss, und die Serialisierung wäre still weg.Den sechsten Aufrufer erreicht es nicht: ein
scanimagevon Hand, Chromes Geräteaufzählung. Eine GRENZE, keine Lücke.
Und _run räumt bei jeder Beendigung auf, vor allem bei
CancelledError. Vorher tötete nur die Zeitablauf-Bahn den Prozess; wurde die
wartende Aufgabe abgebrochen, blieb ein scanimage stehen, das das USB-Gerät
weiter hielt — der nächste Aufruf fand „no Fujitsu scanner found", ein
Fehlerbild, das wie ein Gerätedefekt aussieht und von uns stammt.
watcher_health — das Werkzeug, auf das die Probe schaut
Die Lebendigkeit oben ist nur dann etwas wert, wenn jemand sie LIEST. Bis
2026-10-21 tat das niemand: der Scanner hatte im Backend gar keine
Funktionssonde, mit dieser Begründung in mcp_servers.yaml — „liest down,
wenn der Rechner schläft, erwartet, kein Fehler". Für den schlafenden Mac
stimmt das, und das Rahmenwerk überspringt Proben für getrennte Server
ohnehin. Nur hat dieselbe Begründung den zweiten Fall mitgedeckt: Dienst
läuft, Knopf tot. Neun Tage, mit lebender Aufsicht und grüner Lebendigkeit.
🛑 Gesondet wird NICHT scanner_status. Das fasst das Gerät an und gibt
bei fehlendem Gerät eine Fehlerantwort zurück — ein ausgeschalteter Scanner ist
der Normalfall, eine Probe darauf schlüge dauernd an und würde abgeschaltet.
Eine Probe, die am Normalfall anschlägt, ist schlimmer als keine.
watcher_health liest nur Kostenloses — die Zeitmarke des Wächters (im
Speicher) und den USB-Knoten über ioreg — und fasst das Gerät nicht an.
Nach dem 2026-10-21 ist das keine Nebensache: dort war der Scanner neun Tage am
Bus angemeldet (unveränderte sessionID) und für SANE unansprechbar, OHNE dass
eine einzige Gerätesuche gelaufen wäre. Eine Probe im Takt, die selbst öffnet,
wäre Last auf genau dem Anschluss, dessen Gesundheit sie messen soll. Ein Test
hält fest, dass sie SANE nicht aufruft — mit einer Gegenprobe, dass
scanner_status es sehr wohl tut.
Wächter |
| USB-Knoten | Ergebnis |
| — | — | ok |
| — | — | Befund ( |
|
| fehlt | ok — Scanner aus, der Normalfall |
|
| da | Befund ( |
|
| unbekannt | ok — eine fehlende Messung ist kein Fehler |
|
| — | ok |
Die vorletzte Zeile ist die, die man vergisst: liefert ioreg keine Auskunft,
wird daraus KEIN Befund. Sonst tauscht man einen verschwiegenen Ausfall gegen
einen erfundenen.
Stanza im Backend: health_probe: {tool: watcher_health, interval: 900, expect: {min_items: 0}}. Mit der Hysterese (2) meldet sich ein Befund nach
rund 30 Minuten.
Ein laufender Scan ist kein Defekt
🛑 Beim ersten echten Knopfdruck nach dem Umbau gemessen (2026-10-21):
14:14:49 Knopf gedrückt — starte Scan (Papier: ja)
14:14:54 Sensor nicht lesbar (scanimage endete mit 1: open of device failed)
14:15:25 Gerät gefunden, Sensor wieder lesbar nach 4 Fehlversuch(en)Der Scan hält den Einzug, also MUSS ein Sensorabruf daneben scheitern. Das
hatte zwei falsche Folgen: watcher_health sah device_readable=False bei
vorhandenem USB-Knoten und meldete device_wedged — ein Fehlalarm auf den
normalsten Zustand überhaupt —, und nach drei Fehlschlägen löste der
Fehlerpfad eine 6-Sekunden-Vollsuche aus, mitten im laufenden Scan.
Beide hängen an einer Frage, und die hat EINE Quelle: sane.device_held(),
ein Zähler in dem Modul, das das Gerät öffnet. Der Wächter überspringt den
Takt, und die Probe meldet ok.
🛑 Die Quelle ist die PHASE, nicht der Auftrag — und das ist der Unterschied
zwischen begrenzt und unbegrenzt. Die erste Fassung fragte
JobManager._active, also „läuft ein Auftrag". Das umfasst Zusammenbau, OCR
und Klassifizierung, und img2pdf/ocrmypdf laufen über asyncio.to_thread,
sind also unabbrechbar. Eine hängende OCR hätte den Auftrag für immer
„laufend" gehalten: die Probe dauerhaft verstummt, die Zeitmarke grün, der
Knopf tot — der Neun-Tage-Ausfall durch eine neue Tür. Im Review zu #10
gefunden.
Seit #11 tragen beide Aufrufe eine abgeleitete Zeitgrenze (60 s + 20 s/Seite, s. „Das Zeitbudget des Zusammenbaus"), unbegrenzt ist dort also
nichts mehr. Die Wahl bleibt trotzdem richtig, und zwar deutlicher als
vorher. Das Budget gilt je UNTERPROZESS, der Ausfall aber je AUFTRAG: ein
voller Einzug ergibt duplex 100 Seiten → B = 2060 s, und ein assemble
zahlt es zweimal (img2pdf, dann ocrmypdf) → 2·B ≈ 69 min, das
11-Fache von LIVENESS_STALE_AFTER (370 s). Dazu kommt correct_file, das
weiterhin unbegrenzt je Seite läuft.
Eine Grenze, die zehnfach jenseits des Lebendigkeitsfensters liegt, taugt nicht als Lebendigkeitsgrenze. Die Phase ist die Quelle, nicht der Auftrag — und diese Zahl ist der Grund, warum das so bleiben muss.
Bis #13 war es 4·B ≈ 2 h 17 min, weil assemble ZWEIMAL über denselben
Stapel lief: der Klassifizierer baute eine Sonden-PDF, um Text daraus zu
lesen, und _deliver baute dieselbe PDF danach noch einmal. route_scan war
schlimmer — es baute bedingt („falls noch keine da ist") und liess _deliver
danach bedingungslos erneut bauen, der bedingte Aufruf war also vollständig
wirkungslos. Seither nimmt _deliver eine fertige PDF an (pdf_path): die
Sonden-PDF IST die Lieferung, und ein Stapel, der schon eine PDF trägt, wird
nicht neu gebaut. Die Tests ZÄHLEN die Aufrufe, sie behaupten keine Laufzeit.
⚠️ Was die Wiederverwendung kostet, ausdrücklich entschieden: scheitert die
Texterkennung, behält assemble das PDF ohne Textschicht (das ist der
gewollte Rückfall — ein Dokument geht nie verloren, weil die OCR scheiterte).
Vorher baute route_scan neu und versuchte die OCR dabei ERNEUT; jetzt wird
das textlose PDF unverändert ausgeliefert. Das ist bewusst so: ein zweiter
Versuch kostet den vollen Durchlauf und hat keinen Grund, anders auszugehen —
die Ursachen sind fehlende tessdata, eine unlesbare Seite oder ein
abgelaufenes Budget, und keine davon ändert sich durch Wiederholen. Wer die
Textschicht braucht, scannt neu. Im Review zu #13 gefunden und hier
festgehalten, damit es keine stille Verhaltensänderung bleibt.
Nebenwirkung des Doppellaufs, die niemand gesucht hatte: output_name stempelt
minutengenau, zwei Läufe über eine Minutengrenze hinterliessen also ZWEI PDFs
im Stapel — eine davon eine Waise. Und correct_file korrigiert an der Stelle,
lief also auch zweimal; gegengemessen verschiebt der zweite Durchlauf maximal
EINEN Helligkeitswert je Kanal, kostete somit Zeit und nicht Qualität.
Das Gerät wird nur während scan_batch gehalten, und das ist durch sein
eigenes timeout=1800 begrenzt. Unbegrenztes Schweigen ist damit
strukturell unmöglich, ohne eine gewählte Frist. Nebengewinn: nach dem Scan
ist der Einzug frei, der Knopf ist während der OCR bedienbar statt unnötig
taub.
Ein Zähler, kein Schalter: ein Schalter, den zwei Pfade setzen und einer
zurücknimmt, bleibt irgendwann hängen. Er wird im finally geräumt, auch bei
einer Ausnahme — ein Test hält genau das fest, denn ein hängender Zähler ist
das unbegrenzte Schweigen.
🛑 Der übersprungene Takt setzt die Zeitmarke trotzdem. Ein Scan darf
1800 s dauern, LIVENESS_STALE_AFTER liegt bei 370 s — ohne die Marke
erklärte die Aufsicht den Knopf bei JEDEM langen Scan für tot. Genau der
Fehlalarm, den das Nicht-Sperren von scan_batch vermeiden soll.
🛑 pressed_before bleibt im Überspring-Zweig UNBERÜHRT. Es dort auf
False zu setzen erfände eine Flanke: endet der Scan, während der Knopf noch
gedrückt ist, läse der nächste Takt pressed=True bei pressed_before=False
und startete einen zweiten Scan — mit leerem Einzug. Ein Druck, den der
Wächter schon gesehen hat, ist EIN Druck.
Die Fertigmeldung eines Knopf-Scans hat keinen Empfänger
Eine Fertigmeldung wird an die session_id des anfordernden Chats gebunden.
Ein Knopfdruck hat keine Sitzung — die Gegenseite antwortet 409 unknown job,
und der Eintrag entsteht dort NIE.
🛑 Eine blanke 409 ist mit Absicht wiederholbar: ein sofort scheiternder
Scan kann melden, bevor der Anforderer notiert ist. Für den Knopf gilt das
nie. Das Ereignis führt deshalb caller mit, und nur bei caller == "button"
trägt die 409 {"reason": "no_recipient_for_caller"} — dasselbe Muster wie
bei der 404: endgültig ist eine Ablehnung nur MIT Merkmal.
Das DOKUMENT ist davon unberührt — es geht über den getrennten Ingest-Weg und ist längst abgelegt, bevor die Meldung scheitert. Offen bleibt allein, WEM eine Knopf-Meldung gälte; das Trennblatt entscheidet die Ziel-INSTANZ, nicht den Empfänger darin.
Die freie Diagnose am USB-Knoten
usb.py liest über ioreg die Registratur des Kernels. Das öffnet das Gerät
NICHT, belegt den Anschluss nicht und kann nichts verklemmen — darum darf es
neben dem Geräte-Budget stehen. Ein Aufruf je Ereignis (Fund, Fehlschlag),
nicht je Takt: er hängt an der gedrosselten Warnzeile.
Er trennt „Scanner aus" von „Scanner verklemmt" — beides ergab vorher
ok:false. Beim Stromlosmachen wechselt die sessionID, der SANE-Name
:10551 bleibt; locationID ist das zweite Merkmal, weil ein Verbund- oder
Hub-Knoten mehr als einen Treffer liefern kann.
🛑 Auswahl über idVendor/idProduct, NIE über Einrückung. Die erste
Fassung las die Textausgabe von ioreg und trennte die Geräte an ^\+-o;
verschachtelte Knoten sind eingerückt und fallen in den Block ihres
Elternteils. Der Treffer „ScanSnap" lag im Block eines Monitors, und die
gelieferte sessionID war dessen — eine plausible Zahl vom falschen Gerät,
die schlimmste Sorte Messwert, weil sie in der Auswertung nicht auffällt. Jetzt
über den XML-plist (ioreg -a), ein Baum mit eigenen Merkmalen je Knoten. Ein
Test hält fest, dass bei ABWESENDEM Scanner keine fremde Kennung zurückkommt.
🛑 Die Grenze der Auskunft: im verklemmten Zustand ist der Knoten weiterhin VORHANDEN (gemessen). Sie ist ein Abbruch- und Diagnosesignal, kein Aufwachsignal — „Knoten da" beweist nicht „Gerät benutzbar".
Tools
Tool | Purpose |
| The attached scanner, its settings, configured targets |
| Real hardware state from the SANE sensors — never inferred — plus whether the button watcher is alive |
| START a scan job (feed, correct, route, push in the background); returns a |
| How a job stands or ended — for an explicit question, not for polling |
| Scans held here awaiting a decision or a retry |
| Re-send scans kept after a failed push |
| Render a printable sheet per configured target |
| Decide where a waiting scan belongs and file it |
| Recent routing decisions and the reason for each |
Install (macOS operator host)
python3 -m venv .venv && ./.venv/bin/pip install -e '.[dev]'
cp config/targets.example.yaml targets.yaml # edit
SCANNER_TARGETS_YAML=targets.yaml ./.venv/bin/renfield-mcp-scanner-scan # preflightThen the LaunchAgent in launchd/. Preflight exits non-zero if the scanner is
unreachable or a token is unset, so a launch script can gate on it.
The vendor scanner software must not be running or in Login Items. It claims the USB device exclusively, which blocks this server entirely.
TLS to a self-hosted backend
A self-hosted Renfield often serves a private or self-signed certificate. curl
may accept it from the OS trust store while Python does not (httpx verifies
against certifi), so a push fails with CERTIFICATE_VERIFY_FAILED even though
the same URL works fine in a shell. Point the target's ca_bundle at the PEM.
Verification is never disabled — an unverified push would send documents to whatever answers the hostname.
Two credential directions, both per-party
variable | direction | granularity |
| scanner → Renfield (pushing documents) | one per target |
| Renfield → scanner (calling tools) | one per caller |
| scanner → the calling Renfield (job completion events) | names the caller's target; reuses that target's push token |
Both are per-party for the same reason: a shared secret cannot be revoked for one party without breaking the others. The server refuses to bind a non-loopback address unless at least one caller token is configured.
Scan quality
Six defects were measured and fixed once, in pipeline.py, pdf.py and
sane.py, rather than rediscovered: SANE defaults to US Letter and eats 17.7mm
off every A4 page; ocrmypdf's default optimizer transcodes to lossy JPEG; never
deskew twice; scanner-side --swdeskew creates skew on sparse back pages;
raw colour is strongly blue; duplex show-through needs explicit clearing. Each
carries its measurement in a comment at the point it is handled.
Scan options and page geometry
Setting | Default | Meaning |
|
| SANE source when a request does not choose |
| empty |
|
|
| A4 width. The fujitsu backend defaults to US-Letter width (215.9 mm, 2550 px at 300 dpi); sane-fujitsu(5) says page-width is "used by scanner to determine centering of scan coordinates when using the ADF", so 210 narrows the window symmetrically around the centred paper path. |
|
| A4 height (SANE defaults to US Letter and cuts 17.7 mm off) |
After the first scan with the new width, check one page: pdfimages -list
should show 2480 px wide (210 mm at 300 dpi) and both paper edges should be
on the page. That is the one thing the documentation states and no test here
can prove.
Blank back sides (blank.py)
Audit 2026-10-31 (last 30 batches, both instances): 24 % / 33 % of all pages
were near-blank BACK sides of single-sided paper fed in duplex. They stalled
Renfield's PDF-split review (8 of 30 batches, up to ten days — the
lowest-confidence segment was a blank page) or were filed as blank documents.
--swskip cannot be tuned against it: a content page measured 7.62 % dark,
its reverse 3.97 %.
Dark by default. SCANNER_BLANK_BACKS picks the mode:
Mode | What happens |
|
| nothing is measured — the scan is exactly as before |
|
| every back side is measured and the verdict |
|
| blank back sides are set aside before assembly |
|
Run measure on real stacks first and read the log: would drop N of M page(s) per scan, and one would drop p000N.png (…) line per page with its
measurements. Because --swskip stays on in measure, a duplex scan can come
back with an odd page count; then every page is measured and the side is
reported as unknown. An unknown value (true, a typo) falls back to off
with a warning.
A back side (even page of an ADF Duplex scan) is dropped only when ALL three
measurements on the colour-corrected page agree:
Gate | Setting | Default |
OCR (tesseract, | — | — |
mean luminance at 1/4 scale is at least |
|
|
ink area inside the margin, full resolution, after a 3x3 median, is below |
|
|
ink pixel = luminance below / channel spread of at least |
|
|
border excluded from the ink count |
|
|
Never dropped: a front side; anything from a simplex scan; anything when a duplex scan has an odd page count (parity unproven); the only page of a scan; never so much that a scan would be empty. Any doubt keeps the page — an unreadable image, OCR that cannot run.
🛑 In drop mode on a duplex scan, --swskip is set to 0. SANE's skip
drops a page inside the backend and renumbers every page after it, which would
turn backs into fronts. This module replaces it.
🛑 It runs BEFORE separator segmentation: the parity belongs to the whole
scan, and a separator sheet's blank back would otherwise open the next
document as a blank first page. The measurement is still taken on the
colour-corrected image (pipeline.correct, in memory) — the same pixels the
PDF gets.
Calibration (probe 2026-10-31)
Synthetic A4 pages at 300 dpi with the S1500's raw blue cast; marks drawn at a
fraction of the raw paper colour, so 0.80 is light but plainly visible after
the correction (~225). Real tesseract; all of these are tests.
Page | ink mm² | verdict |
blank | 0.00 | dropped |
blank with sensor noise, edge shadow, dust | 0.03 | dropped |
show-through of a full text page, raw 0.86 (darkest of the band), diffused / sharp | 0.00 / 0.00 | dropped |
show-through raw 0.90; red logo; blue logo | 0.00 | dropped |
pencil note "bez. 12.10." at 0.80 / 0.75 | 19.3 / 20.4 | kept (ink) |
pencil signature 0.3 mm at 0.80 | 22.1 | kept (ink) |
pencil tick, 10 mm, at 0.80 | 6.3 | kept (ink) |
pale violet / pale blue stamp ring | 53.8 / 53.8 | kept (ink) |
pale red stamp, lighter than the ink level (colour only) | 53.8 | kept (ink, via chroma) |
light-grey print "Kontoauszug Nr. 12 Seite 2" at 0.80 | 21.3 | kept (ink) |
initials, signature, red stamp, highlighter, grey recycled paper with text | 9.4 … 512 | kept (ink) |
Where the thresholds come from: after the correction the darkest show-through lands at 242-246 and a mark at raw 0.80 at 225, so the ink level is 232 (raw ~0.82 of paper). Coloured show-through spreads up to 13 (18 before the median); a colour-only pale stamp spreads 30, so the chroma floor is 24. The first calibration (200 / 60) dropped every faint mark in the table — a test holds that as the negative control.
🛑 The limit: a mark as faint as show-through (raw 0.85 and lighter, e.g.
a pencil note at 0.85 or a pale green stamp at 0.86/0.92/0.86) is
indistinguishable from it and IS dropped. That is why the default is off and
why measure comes first.
🛑 OCR reads a probe, not the page. On the corrected reverse of a text page — show-through at 242-246, invisible to the ink measure — tesseract's adaptive binarisation still found the mirrored lines and read 200 junk "words" ("PIE", "it:"). The text gate would have kept every show-through back. It therefore reads a grey image with everything at or above the ink level set to white. A character budget (the first rule, 30) dropped text OCR had READ; one word now keeps the page.
Evidence and retention
Each dropped page is logged (job id, stage, page, front, luminance, ink,
characters, words — numbers only) and moved to <stage>/dropped/ with
blank-backs.json. In a mixed stack it travels with its front into the
segment's stage. When a stage is discarded after delivery, its dropped/
directory moves to <staging>/dropped-backs/<stage>/ — the clock restarts at
delivery — and the retention sweep removes it after
SCANNER_STAGING_RETENTION_DAYS, like any stage. A wrong drop is therefore
recoverable AFTER delivery, which is when it first shows (a page missing from
the filed document). One INFO line per scan counts why every back side was
kept; backs kept ONLY by the ink floor are listed with their ink area — the
calibration data for the floor.
The job result carries blank_backs_dropped (page numbers). Renfield does not
show it yet: its ScanJobResult ignores unknown fields.
Cost (measured on this Mac): about 0.3 s per back side for the image gates (open, correct, measure), plus 0.4 s of OCR for each page that passes them.
Was eine Seite kostet
Drei Schritte je Seite, alle gemessen und alle off the event loop — ein
blockierender Schritt fror den ganzen Server ein, und ein list_tools vom
Backend blieb 12 s unbeantwortet, lang genug, dass dessen Auffrischung den
Scanner für tot erklärte und den laufenden Aufruf abriss.
Schritt | je Seite | 100 Seiten (voller Duplex-Einzug) |
| 411 ms | 41 s |
| ~1,6 s | ~162 s |
| ~1,15 s | ~120 s |
segment lief bis #15 blockierend auf der Schleife. Das war nicht
theoretisch: 2550×3508 Pixel je Seite, Strichcodesuche auf jeder, und bei
einem halben Einzug schon 20,5 s Stillstand. Jetzt wie assemble in einem
Thread, und ein Test weist das über einen beidseitigen Handschlag nach — ist
die Schleife blockiert, läuft seine Wartezeit ab und er fällt.
Offene Gelegenheit: vor dem Dekodieren verkleinern
decode_page öffnet die Seite in voller Scanauflösung und beeinflusst sie
nirgends; Image.draft() — das einzige Mittel, beim Dekodieren selbst zu
verkleinern — greift bei PNG nicht. Im Review zu #15 an einem nach den echten
Parametern aus sheets.py nachgebauten Blatt gemessen (1200 px QR,
ERROR_CORRECT_H, border=4, plus Blaustich, Rauschen, 1,3° Schräglauf):
Maßstab | Größe | PNG öffnen | zbar | gesamt | erkannt |
1/1 | 2550×3508 | 57,8 ms | 117,3 ms | 175,1 ms | ja |
1/2 | 1275×1754 | 67,4 ms | 17,4 ms | 84,7 ms | ja |
1/3 | 850×1169 | 64,4 ms | 7,3 ms | 71,7 ms | ja |
1/8 | 318×438 | 60,6 ms | 1,3 ms | 61,9 ms | ja |
Dass es bis 1/8 trägt, ist kein Zufall: das Blatt ist dafür entworfen
(sheets.py — großer Code, hoher Kontrast, großzügige Ruhezone, ECC H).
⚠️ Zwei Messungen, die sich scheinbar widersprechen, und die Auflösung gehört dazu: oben stehen 411 ms je Seite, hier 175 ms bei gleicher Auflösung. Die erste lief über echte Scanseiten mit Text, die zweite über ein fast leeres Trennblatt. zbars Kosten hängen am INHALT — eine dichte Textseite gibt ihm viel mehr Kandidatenregionen zu prüfen als ein weißes Blatt mit einem QR-Code. Das ist die plausible Erklärung, nicht gegengemessen. Falls sie stimmt, fällt die Ersparnis in der Praxis sogar GRÖSSER aus als die Tabelle zeigt, denn Inhaltsseiten sind die Mehrheit.
Drei Folgerungen, und die zweite ist die wichtigste:
zbar ist zwei Drittel der Kosten, und bei 1/3 bleibt davon nichts (117 → 7 ms).
🛑 Es gibt einen Boden von ~60 ms je Seite für das Öffnen des PNG. Den zahlt man, sobald man die Seite anfasst — 100 Seiten sind auch bei 1/8 noch ~6 s. Das Verkleinern ist also additiv zu
to_thread, kein Ersatz.convert("L")bringt nichts (175,1 → 175,0 ms): zbar wandelt selbst nach Y800. Eine Vermutung, die ihre eigene Messung widerlegt hat.
🛑 Noch nicht umgesetzt, und zwar aus einem Grund. Die Messung lief an einem SYNTHETISCHEN Blatt: kein Durchscheinen vom Duplex-Rückweg, keine Falte, kein Schmutz, kein echter Papierweißpunkt. Ein zu stark verkleinertes Blatt wird still nicht erkannt, und das misroutet einen Stapel, ohne dass jemand es merkt. Der Entwurf, der die Frage entschärft: 1/3 versuchen, und nur wenn nichts dekodiert, die volle Auflösung nachschieben. Dann kostet ein Fehlschlag den alten Preis statt eines fehlgeleiteten Stapels, und der Rückweg ist dieselbe Funktion statt einer zweiten Codebahn.
Offen ist damit nur noch eine Messung an echtem Papier — ein Trennblatt durch den Einzug.
Das Zeitbudget des Zusammenbaus
img2pdf und ocrmypdf liefen bis #11 ohne Zeitgrenze. Beide laufen über
asyncio.to_thread und sind unabbrechbar, also hätte ein einziger hängender
Kindprozess JobManager._active für immer gesetzt gelassen: jedes start()
antwortet busy, der blaue Knopf ist tot — und watcher_health meldet weiter
ok, weil das Gerät längst freigegeben ist. Ein stiller Dauerausfall, der
aussieht wie ein Gerätedefekt.
Die Grenze ist gemessen, nicht gewählt (2026-10-26, auf dem Produktions-Mac, mit genau den Produktionsflags, über 2/8/20 Seiten):
je Seite | fest | |
| ~1,6 s | 1,5 s |
| ~1,15 s | 5 s |
Budget: ASSEMBLY_OVERHEAD + ASSEMBLY_PER_PAGE × Seiten = 60 s + 20 s/Seite,
12,5× für img2pdf und 17× für ocrmypdf — das Budget gilt je
Unterprozess, nicht über den ganzen Zusammenbau. (Gegen die Summe beider
Messwerte wären es 7×; das ist die Rechnung für ein Budget je Auftrag, und
ein solches ist NICHT gebaut — seit #13 läuft assemble nur noch einmal je
Dokument, die Grenze selbst gilt aber weiter je Unterprozess.) Der
Faktor ist absichtlich großzügig: sie soll ein unbegrenztes Warten begrenzen,
nicht eine langsame Maschine maßregeln — eine dichte Seite oder ein belasteter
Rechner darf seine Texterkennung nicht verlieren.
🛑 Nicht subprocess.run(timeout=...). Das tötet nur das unmittelbare
Kind, und ocrmypdf ist ein Elternteil: ein Lauf hält gemessen sieben
tesseract-Nachkommen. Im gesunden Fall beenden die ihre Seite und gehen binnen
~4 s von selbst — aber der Fall, für den das Budget existiert, ist ein
hängender tesseract, und dort überlebt er den Tod des Elternteils unbegrenzt
und hält einen Kern und das Staging-Verzeichnis. _run_bounded gibt dem Kind
darum mit start_new_session eine eigene Prozessgruppe und schickt bei Ablauf
SIGKILL an die Gruppe. Kehrt die Nachlese danach nicht zurück, hält noch
jemand die Rohre — das ist eine ERROR-Zeile, keine stille Unterdrückung.
ocrmypdf ist der einzige Elternteil im Paket; img2pdf, pdftotext und
scanimage sind Einzelprozesse (die ersten beiden gegengemessen), deshalb
genügt ihnen das Töten des Kindes.
Die zwei Aufrufe scheitern unterschiedlich schwer, und das bleibt so:
img2pdf-Ablauf ist tödlich (AssemblyError) — ohne PDF gibt es nichts
abzuliefern. ocrmypdf-Ablauf ist nur eine weitere Art, auf die die
Texterkennung nicht zustande kam, und läuft deshalb in den bestehenden
Rückfallpfad: das un-OCRte PDF wird behalten, der Grund protokolliert. Ein
Dokument geht nie verloren, weil die Texterkennung scheiterte.
Tests
./.venv/bin/python -m pytest tests/ -qThis server cannot be deployed
Maintenance
Related MCP Connectors
Reads invoices and receipts from PDF to JSON and checks the math. $0.04/page via x402.
Human-in-the-loop document extraction: submit a doc, get validated fields and an audit trail.
Document OCR, vehicle analysis, and workspace usage tools for Scanverion.
Read PDFs, photos and emails into structured fields; check, correct and approve the results.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceWatches local and SMB folders for settled new files and pushes them into the Renfield knowledge base and Paperless over REST; also provides interactive MCP tools for browsing and on-demand file ingestion.2MIT

@parserelay/mcpofficial
AlicenseAqualityDmaintenanceEnables document parsing into structured, confidence-scored fields via the scan tool, working with any MCP host like Claude Desktop or Cursor.144 npmMIT- AlicenseAqualityCmaintenanceEnables scanning documents via network (eSCL) or USB (WIA/SANE) scanners, returning inline images, OCR text, or saved PDFs for Claude to read.21MIT
- AlicenseBqualityCmaintenanceEnables scanner capture via SANE on Linux, providing tools for device discovery, scan jobs with ADF/duplex support, document batching, and multipage assembly through a local MCP server.847 npm6MIT