Skip to main content
Glama

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, a detail that 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_ENABLED was true and the backend log has no job-event line for that second. 401/403 stay final: both arise in the application and are unambiguous. 🛑 The marker is NOT reason: "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_HOURS IN TOTAL, not per service start. Retrying is the safe side of the 404 split, but it amplifies: gave_up events are re-sent on every start with a fresh attempt budget and only expire with SCANNER_STAGING_RETENTION_DAYS, so a wrong base_url in SCANNER_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 as gave_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 and SCANNER_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 as interrupted and 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 save is 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_DAYS have 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 a Retry-After on 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 scanimage -d … -A, also ein voller Öffnungsvorgang

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

state

off (absichtlich aus) · starting (noch kein vollendeter Durchlauf) · alive · dead · crashloop

finding

Wahr bei dead und crashloop — darauf schaut eine Probe

last_tick_age_seconds / stale_after_seconds

Alter der Marke und die Grenze, gegen die verglichen wurde

since

Wanduhrzeit, seit der die aktuelle Schleife läuft (nur Anzeige)

restarts_total / restart_streak

Neustarts der Aufsicht insgesamt, und der Zähler aus ihrer Meldung

device_readable

Ob der letzte Sensorabruf gelang — false ist KEIN Befund

🛑 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_sensors wirft 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.ready ist bei leerem raw False — für jede Herkunft eines Satzes, nicht nur für read_sensors.

  • Damit wird device_readable zum 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

device

wirkt REDISCOVER_AFTER?

A

find_device

bleibt None — die Zuweisung findet nicht statt

nein, device = None ist dann ein Leerlauf

B

read_sensors

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_batch würde bis zu 1800 s gehalten, LIVENESS_STALE_AFTER liegt 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 scanimage von 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

device_readable

USB-Knoten

Ergebnis

off / starting

—

—

ok

dead / crashloop

—

—

Befund (watcher_dead / watcher_crashloop)

alive

False

fehlt

ok — Scanner aus, der Normalfall

alive

False

da

Befund (device_wedged)

alive

False

unbekannt

ok — eine fehlende Messung ist kein Fehler

alive

True / None

—

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

list_scanners

The attached scanner, its settings, configured targets

scanner_status

Real hardware state from the SANE sensors — never inferred — plus whether the button watcher is alive

scan_document

START a scan job (feed, correct, route, push in the background); returns a job_id at once, busy while another scan runs. sides: duplex / simplex (ADF Front) for this scan, empty = SCANNER_SOURCE

scan_job_status

How a job stands or ended — for an explicit question, not for polling

list_pending_scans

Scans held here awaiting a decision or a retry

retry_pending_scans

Re-send scans kept after a failed push

generate_separator_sheets

Render a printable sheet per configured target

route_scan

Decide where a waiting scan belongs and file it

routing_audit

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   # preflight

Then 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_TOKEN_<TARGET>

scanner → Renfield (pushing documents)

one per target

SCANNER_MCP_TOKEN_<CALLER>

Renfield → scanner (calling tools)

one per caller

SCANNER_CALLER_TARGET_<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

SCANNER_SOURCE

ADF Duplex

SANE source when a request does not choose

scan_document(sides=…)

empty

duplex = ADF Duplex, simplex = ADF Front (front only), for this one scan; anything else is refused with invalid_sides before the feeder moves

SCANNER_PAGE_WIDTH_MM

210

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. 215.9 restores the old geometry

SCANNER_PAGE_HEIGHT_MM

297

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

--swskip

off (default)

nothing is measured — the scan is exactly as before

2.0, as before

measure

every back side is measured and the verdict drop WOULD reach is logged (numbers only); nothing is dropped

2.0, as before

drop

blank back sides are set aside before assembly

0 (see below)

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, SCANNER_OCR_LANGUAGES) reads no WORD — three letters or four digits in a row

—

—

mean luminance at 1/4 scale is at least

SCANNER_BLANK_MIN_LUMINANCE

253

ink area inside the margin, full resolution, after a 3x3 median, is below

SCANNER_BLANK_INK_FLOOR_MM2

2.0 mm²

ink pixel = luminance below / channel spread of at least

SCANNER_BLANK_INK_LEVEL / SCANNER_BLANK_INK_CHROMA

232 / 24

border excluded from the ink count

SCANNER_BLANK_MARGIN_MM

5

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)

separator.segment (PIL + pyzbar je Seite)

411 ms

41 s

img2pdf

~1,6 s

~162 s

ocrmypdf

~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:

  1. zbar ist zwei Drittel der Kosten, und bei 1/3 bleibt davon nichts (117 → 7 ms).

  2. 🛑 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.

  3. 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

img2pdf

~1,6 s

1,5 s

ocrmypdf

~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/ -q

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Watches 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.
    2
    MIT
  • A
    license
    A
    quality
    D
    maintenance
    Enables document parsing into structured, confidence-scored fields via the scan tool, working with any MCP host like Claude Desktop or Cursor.
    1
    44 npm
    MIT
  • A
    license
    B
    quality
    C
    maintenance
    Enables 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.
    8
    47 npm
    6
    MIT