DE

Ein Gedächtnis aus Tickets

, Korhan Gülseven

This post is only available in German.

Ein Agent vergisst zwischen zwei Sessions alles. Beads legt ihm deshalb ein Ticketsystem ins Projekt, in das er schreibt, was er entschieden hat und was noch offen ist. Hineinschreiben habe ich ihm vorgeschrieben, nachschlagen nicht.

Zum Prüfen brauchte ich etwas Grosses: Quiet Edit, ein Detektor für stille Redigierungen an Nachrichtenartikeln. Feeds einlesen, Volltext holen, jede Fassung versionieren, Änderungen finden. Es sind vier Schritte, von denen jeder eine eigene Art hat, falsch zu sein.

Ich wollte etwas mit Biss. Gekriegt habe ich einen sibirischen Tiger.

Was Beads macht

Beads ist ein Ticketsystem, das für den Agenten gebaut ist, nicht für ein Board. bd ready nennt die Tickets, die nicht blockiert sind. bd update <id> --claim nimmt eines. bd close <id> --reason "..." schliesst es mit einer Begründung, die im Graphen bleibt. bd create --deps discovered-from:<id> hängt Arbeit an, die unterwegs aufgefallen ist. Der Graph liegt im Projekt und lässt sich auf Wunsch mit git versionieren.

Die zwanzig Tickets unter drei Epics zum Start habe ich von Hand geschnitten und mit Claude ausformuliert: Zuschnitt, Reihenfolge und Prioritäten von mir, Beschreibungen und Akzeptanzkriterien im Dialog.

[🤖]  quietedit git:(main) bd list --all
○ quietedit-3gj ● P0 Foundation: entities, repositories, Flyway V1
 quietedit-b72 ● P0 task Bootstrap: project skeleton, Docker, datasource
○ quietedit-10i ● P1 [epic] Ingest
├── ○ quietedit-6yt ● P1 Ingest orchestration
├── ○ quietedit-82r ● P1 Feed fetcher
├── ○ quietedit-8he ● P1 Article fetcher
├── ○ quietedit-94i ● P1 Feed parser (RSS 2.0 + Atom)
├── ○ quietedit-aph ● P1 Boilerplate removal
├── ○ quietedit-l2s ● P2 Encoding resolution
└── ○ quietedit-m4p ● P2 Date normalisation
○ quietedit-cca ● P1 [epic] Identity & Versioning
├── ○ quietedit-qu2 ● P0 Version store
├── ○ quietedit-1hs ● P1 Diff engine
├── ○ quietedit-1nk ● P1 URL canonicalisation
├── ○ quietedit-dt5 ● P1 Change classification
├── ○ quietedit-es7 ● P1 Content hash
├── ○ quietedit-t0j ● P1 Re-check policy
└── ○ quietedit-cab ● P2 Title tracking
○ quietedit-39m ● P2 [epic] Analysis & Output
├── ○ quietedit-0un ● P1 Change store
├── ○ quietedit-eyk ● P2 REST API
├── ○ quietedit-rzo ● P3 Web interface
└── ○ quietedit-v1m ● P3 Cross-source clustering

--------------------------------------------------------------------------------
Total: 23 issues (22 open, 0 in progress)

Status: ○ open  ◐ in_progress  ● blocked  ✓ closed  ❄ deferred

Als am Schluss die Weboberfläche stand, waren es 63 Tickets. 23 stammen aus meiner Planung, 40 kamen unterwegs dazu. 33 davon hat der Agent selbst mit discovered-from an das Ticket gehängt, aus dem die Arbeit aufgefallen war. Der Graph ist am Ende überwiegend nicht das, was ich vorhatte, sondern das, was beim Bauen sichtbar wurde.

Die 40 zusätzlichen Tickets sind kein Scope Creep. Meine 23 waren eine Hypothese über den Schnitt. Die Implementierung hat daraus eine feinere Zerlegung gemacht, und weil jedes neue Ticket per discovered-from an seinem Ursprung hängt, ist nachlesbar, an welcher Stelle die Hypothese nicht mehr reichte. Aus „Boilerplate removal“ wurden vier Tickets. Dass sich eine Zerlegung beim Bauen verschiebt, ist nicht neu. Neu ist, dass die Verschiebung durch einen Agenten selbst strukturiert dokumentiert wird, weil das Protokoll es verlangt.

Ein Ticket pro Session

In CLAUDE.md steht unter Session protocol, wie eine Session abläuft. Sie bearbeitet genau ein Ticket, und welches das ist, bestimmt bd ready -t task. Epics sind Container und werden nie selbst bearbeitet. Implementiert wird inklusive Tests. Was unterwegs auffällt und nicht ins Ticket gehört, wird ein eigenes Ticket, nicht still nebenbei erledigt und auch nicht ignoriert. Geschlossen wird mit einer Begründung, die sagt, was gebaut wurde, welcher Entscheid gefallen ist und was offen bleibt. Ein „Done“ als Reason ist unbrauchbar.

Steve Yegge, der Autor von Beads, nennt den geordneten Abschluss einer Session Landing the Plane: die Arbeit erst abschliessen, dann den Kontext hinterlassen, den die nächste Session nicht mehr hat. Er beschreibt, was der Agent dabei zu tun hat. Wer die Arbeit danach prüft und integriert, bleibt offen. Genau dort steht meine Regel: Zum Schluss legt er einen Branch an, committet, pusht und öffnet einen Pull Request. Nie auf main, nie selbst mergen.

Im Review fand ich Dinge, die keinen Test rot machen: eine Flyway-Nummer, die ein zweites Ticket schon beansprucht hatte, und eine Unique-Constraint aus V1, die genau den Fall verhindert, den das Projekt sehen will: einen Artikel, der zu einer früheren Formulierung zurückkehrt. Beides stand im PR-Text, beides hätte ich beim Durchwinken übersehen.

Zehn Sessions ins Leere

Die ersten zehn Sessions liefen nach demselben Muster. Er nimmt sein Ticket, liest es, baut, schliesst es mit einer ordentlichen Begründung, legt zwei neue Tickets an. Gelesen hat er darin nichts. Der Graph wuchs, und mit ihm der Verdacht, dass ich einem Agenten nur Buchhaltung beigebracht hatte und sonst nichts. Beads war ein Briefkasten: Einwurf oben, nie geöffnet.

Dann das:

21:37:46  human  -         show quietedit-10i.7
21:38:44  human  -         update quietedit-10i.7 -p 1
21:38:53  human  -         ready
...
21:42:35  agent  c1757d43  ready -t task
21:42:40  agent  c1757d43  update quietedit-10i.7 --claim
21:42:40  agent  c1757d43  show quietedit-10i.7
21:43:10  agent  c1757d43  list --status=open
21:43:10  agent  c1757d43  search flyway
21:43:18  agent  c1757d43  show quietedit-qu2
21:43:18  agent  c1757d43  show quietedit-dt5
21:43:19  agent  c1757d43  show quietedit-es7
21:43:19  agent  c1757d43  show quietedit-m4p
21:43:19  agent  c1757d43  show quietedit-cab

Oben stehe ich: Ticket ansehen, hochpriorisieren, prüfen, was jetzt oben liegt. Innert vier Minuten nimmt er genau dieses Ticket.

Dreissig Sekunden nach dem Aufschlagen seines eigenen Tickets sucht er nach einem Wort, das darin nicht vorkommt. Flyway-Versionsnummern hatten sich zweimal überschnitten, weil zwei Tickets dieselbe verlangten. Er findet qu2, ein Ticket, das nicht seines ist, und liest dort die Flyway-Zeile aus meiner Planung. Sie verlangt V2, eine Nummer, die er selbst zwei Sessions davor verbraucht hatte. Er nimmt V3 und hängt die Notiz ans fremde Ticket. Zwei Sessions danach kam das Ticket dran, auf dem die Notiz lag. In der Übergabe stand: „The ticket's Flyway line said V2, which V2__article_attempt.sql had already taken; V4 was the next free number.“ Ich hatte die Notiz nie erwähnt.

10i.7 verlangte eine Flyway-Migration, ohne die Versionsnummer zu nennen. Eine Nummer, die schon vergeben ist, bricht beim nächsten Start. Das war die erste Stelle, an der im Ticket etwas fehlte, das er nicht selbst entscheiden konnte.

Ein Agent muss sich an nichts erinnern. Er muss den früheren Entscheid nur finden, entweder beim Lesen des eigenen Tickets oder über eine Suche. Er schlug nicht nach, weil er gelernt hatte. Er schlug nach, weil das Ticket nicht reichte. Zehn Sessions lang reichten meine Tickets. Deshalb las er nicht.

Warum die Welt nicht mitspielt

Ab hier stand das Verfahren. Und dann fing der Tiger an, sich zu wehren.

Schwierig ist, eine Textänderung erkennen zu wollen in einem Web, das sich bei jedem Abruf leicht anders gibt. Wer stille Redigierungen finden will, muss zuerst alles wegrechnen, was sich ändert, ohne dass jemand etwas geschrieben hat.

Jedes geschlossene Ticket brachte zwei neue mit: ein Verlag streut data-*-Attribute, an denen die Boilerplate-Erkennung vorbeiläuft. Ein Liveticker verliert seine letzten Einträge in Bildunterschriften. Ein Bezahlartikel kommt anonym als drei Absätze und wird als vollständige Fassung gespeichert. Dazu Werbung, überall.

Gebaut war das jeweils in Minuten. Die Zeit ging für die Entscheide davor drauf: Wie oft prüft man einen Artikel nach? Widersprechen sich die Encoding-Deklarationen, wird geraten oder mit Ersatzzeichen dekodiert? Ist eine Zwischenüberschrift mit 36 Prozent gemeinsamer Wörter eine Korrektur oder ein Austausch? Jede Frage kam mit zwei Varianten und einer Empfehlung, und bei jeder musste ich erst verstehen, worin der Unterschied besteht.

Der zäheste Fall ging über drei Tickets. Zwei Verlage teilen sich einen Artikel, der eine setzt rel=canonical auf den anderen. So entsteht ein Dokument mit der Identität des einen und dem Text des anderen, das bei jedem Lauf zwischen Schweizer und deutscher Orthographie wechselt. Gebaut, getestet, geschlossen. Und im selben Close-Reason der Hinweis, dass derselbe Artikel in zwei Feeds die Sache sofort wieder aufmacht. Das Ticket dazu ist pendent.

Über Feed-Syndikation, Boilerplate und SEO dirty tricks habe ich in diesem Experiment mehr aus Close-Reasons und Javadoc gelernt als aus jeder Praxis davor.

Die Detailansicht von Quiet Edit: zwei Fassungen eines srf.ch-Artikels nebeneinander, darunter der geänderte Absatz mit der ersetzten Stelle hervorgehoben
Aus «krimineller Misswirtschaft» wird «ungetreuer Geschäftsbesorgung». Dieselbe URL, kein Hinweis am Artikel.

Quiet Edit läuft lokal. docker compose up startet die Datenbank, mvn spring-boot:run die Anwendung, die Weboberfläche läuft auf localhost:8080. Neu ist die Idee nicht: NewsDiffs verfolgt seit 2012 die Fassungen bei NYT, CNN und BBC, diffengine macht dasselbe über RSS.

Was er nicht getan hat

Die letzte Session, an der Weboberfläche:

23:41:50  agent  89d58c44  update quietedit-39m.9 --claim
23:41:51  agent  89d58c44  show quietedit-39m.9
23:47:49  agent  89d58c44  create --title=Carry the seconds on the index's
          Last changed column too --parent=quietedit-39m
          --deps discovered-from:quietedit-39m.9
          --description=... The index's 'Last changed' column has the same
          problem and did not get the attribute: the ticket named the diff
          page only, so it was left alone rather than widened silently.

Er hätte die zweite Spalte mitnehmen können, niemand hätte es gemerkt, und im PR hätte es besser ausgesehen. Stattdessen steht die Grenze im Ticket, mit dem Grund. Das ist der Unterschied zwischen 26 pendenten Tickets und 26 Zeilen Rauschen: Jedes benennt eine Stelle, an der jemand aufgehört hat, und sagt warum.

Zweimal hat er ausserdem seinen eigenen Fix zurückgenommen, um zu prüfen, ob die neuen Tests gegen das alte Verhalten wirklich fallen. Beide fielen.

[🤖]  quietedit git:(main) bd list --all
 quietedit-3gj ● P0 task Foundation: entities, repositories, Flyway V1
 quietedit-b72 ● P0 task Bootstrap: project skeleton, Docker, datasource
○ quietedit-10i ● P1 [epic] Ingest
├──  quietedit-10i.2 ● P0 task Bound the work one ingest run may do
├──  quietedit-10i.3 ● P0 task Prevent overlapping ingest runs
├──  quietedit-10i.4 ● P0 task Candidates that never yield a document keep the front of the budget queue
├──  quietedit-dlo ● P0 task Parse feed in a single jsoup pass
├──  quietedit-10i.7 ● P1 task Carry the encoding verdict past decoding
├──  quietedit-10i.10 ● P1 bug Furniture detection ignores data-* attributes
├──  quietedit-10i.11 ● P1 bug A ticker's last entries are lost with the rail captions
├──  quietedit-10i.14 ● P1 bug A ticker's own summary line is rewritten every render and diffs as a word removal
├──  quietedit-6yt ● P1 task Ingest orchestration
├──  quietedit-82r ● P1 task Feed fetcher
├──  quietedit-8he ● P1 task Article fetcher
├──  quietedit-94i ● P1 task Feed parser (RSS 2.0 + Atom)
├──  quietedit-aph ● P1 task Boilerplate removal
├──  quietedit-10i.1 ● P2 Goldset for boilerplate extraction from real fetched pages
├──  quietedit-10i.5 ● P2 Reconsider abandoned article links
├── ○ quietedit-10i.9 ● P2 Detect furniture by comparing many articles of one host
├── ○ quietedit-10i.12 ● P2 [bug] A ticker entry's body is lost because the publisher wraps it in a div
├──  quietedit-3jl ● P2 Unify retry and backoff between feed and article fetching
├──  quietedit-74z ● P2 Detect truncation structurally instead of via jsoup ranges
├──  quietedit-h1e ● P2 Durable retention of raw feed responses
├──  quietedit-k2v ● P2 Retention and pruning for the raw HTML store
├──  quietedit-l2s ● P2 task Encoding resolution
├──  quietedit-m4p ● P2 task Date normalisation
├──  quietedit-10i.6 ● P3 Prune article_attempt rows for links no feed advertises any more
├── ○ quietedit-10i.8 ● P3 Drive the ingest integration test from a fixed clock
└── ○ quietedit-10i.13 ● P3 [bug] An app-push promo box reaches the extraction as five paragraphs
○ quietedit-cca ● P1 [epic] Identity & Versioning
├──  quietedit-1hs ● P1 task Diff engine
├──  quietedit-1nk ● P1 task URL canonicalisation
├──  quietedit-cca.7 ● P1 task Ignore an updated field a publisher stamps on every render
├──  quietedit-cca.9 ● P1 bug A syndicated article alternates between two publishers' texts forever
├──  quietedit-es7 ● P1 task Content hash
├──  quietedit-t0j ● P1 task Re-check policy
├── ○ quietedit-cab ● P2 Title tracking
├──  quietedit-cca.2 ● P2 task Allow a document to return to a wording it already published
├── ○ quietedit-dt5 ● P2 Change classification
├──  quietedit-qu2 ● P2 task Version store
├── ○ quietedit-cca.1 ● P3 Share one text-folding implementation between extractor, hasher and diff engine
├── ○ quietedit-cca.3 ● P3 Drop the redundant single-column content_hash index
├── ○ quietedit-cca.4 ● P3 Verify the version store writes the encoding verdict
├── ○ quietedit-cca.5 ● P3 Age an article by its publication date, not by when it was discovered
├── ○ quietedit-cca.6 ● P3 Count the per-host hourly ceiling from the requests actually made
├── ○ quietedit-cca.8 ● P3 Let a reformed feed earn back its updated claims from retired articles too
├── ○ quietedit-cca.10 ● P3 [bug] Guard the lastChangedAt write so it cannot move backwards
└── ○ quietedit-cca.11 ● P3 [bug] Close the syndication class: two feeds, two origins, one canonical URL
○ quietedit-39m ● P2 [epic] Analysis & Output
├── ○ quietedit-0un ● P1 Change store
├──  quietedit-39m.1 ● P1 task Expose diffs over REST
├──  quietedit-39m.3 ● P1 task List a document's revisions over REST
├──  quietedit-39m.5 ● P1 task A web frontend to look at changes and diffs
├──  quietedit-39m.8 ● P2 task Show per-feed totals next to the index filters
├── ○ quietedit-eyk ● P2 REST API
├── ○ quietedit-39m.2 ● P3 Diff headings and paragraphs by their own rules instead of one similarity bar
├── ○ quietedit-39m.4 ● P3 Cover the revisions endpoint with a test
├── ○ quietedit-39m.6 ● P3 Add the Maven wrapper so ./mvnw works
├── ○ quietedit-39m.7 ● P3 Page the change listing instead of capping it at 200
├──  quietedit-39m.9 ● P3 task Render timestamps in the reading interface the same way everywhere
├── ○ quietedit-39m.10 ● P3 Carry the seconds on the index's Last changed column too
├── ○ quietedit-rzo ● P3 Web interface
└── ○ quietedit-v1m ● P3 Cross-source clustering

--------------------------------------------------------------------------------
Total: 63 issues (26 open, 0 in progress)

Status: ○ open  ◐ in_progress  ● blocked  ✓ closed  ❄ deferred

Der Tiger hat gewonnen

Er hat zehn Sessions gebraucht, um anzufangen zu lesen. Ich habe vierzig gebraucht, um aufzuhören.

Nicht am Code. Der war in Ordnung, und die Begründungen waren es auch. Aber jede Begründung enthielt eine Ausnahme, jede Ausnahme eine beobachtete Seite, die sich anders verhält als die anderen. Ein Absatz über Wortüberdeckung bei Überschriften, ein Absatz darüber, dass zweistellige Jahreszahlen ins Fenster 1970 bis 2069 fallen. Irgendwann prüfte ich noch, ob ein Entscheid begründet war, nicht mehr, ob er richtig ist. Eine Begründung zu schreiben kostet ihn zwanzig Sekunden. Sie nachzuvollziehen kostet mich zehn Minuten. Das Gedächtnis skaliert, mein Review nicht.

Meine Timebox für das Experiment war da lange verbraucht. Die Liste der Grenzen in der README ist länger als die Beschreibung dessen, was funktioniert, und sie ist mit Sicherheit unvollständig.

Geblieben ist eine Sache: Als ich aufhörte, stand jeder Entscheid dort, wo der nächste ihn sucht. Nicht in einem Chatfenster von vorletzter Woche. Das ist die Voraussetzung für den nächsten Versuch: fünf Agenten parallel, die sich ihre Tickets selbst schneiden und selbst entscheiden, welches als nächstes dran ist. Was ihnen dann fehlt, ist nicht das Gedächtnis. Sondern ein Mechanismus, der aus sechzig Tickets die drei findet, die für die nächste Entscheidung zählen.


Find me by email, on LinkedIn or via Zooey.