HEUTE ZWEIMAL NUR GUTGEGANGEN. Zwei Claude-Sitzungen arbeiteten
gleichzeitig in diesem Verzeichnis, ohne voneinander zu wissen.
Keine hat etwas falsch gemacht — sie konnten es nicht wissen.
* Beide haben `git add -A` benutzt. Haette die eine
unfestgeschriebene Arbeit der anderen im Baum gehabt, waere sie
mitcommittet worden — unter fremdem Namen, in einer fremden
Begruendung, und niemandem waere es aufgefallen. Nachgesehen:
diesmal war nichts dabei.
* Beide haben den Stempellauf gestartet. Der schreibt 45 Dateien
um. Wer dort eine offen hatte, bekam sie unter den Haenden weg
geaendert.
Dasselbe hat in RunOne am 03.09.2026 sieben Minuten Ausfall
gekostet. Dort gibt es seitdem `arbeitsschloss.sh`; diese Fassung
uebernimmt seine Lehren.
WAS ES IST UND WAS NICHT. Es ist kein Riegel — wer wirklich muss,
kommt vorbei. Es beantwortet die eine Frage, die heute niemand
beantworten konnte: „arbeitet hier gerade sonst jemand?"
Die teuren Fehler liegen bei einem Schloss alle in derselben
Richtung: Es blockiert zu viel und wird deshalb abgeschafft. Also:
* Es blockiert NICHT, wenn das Schloss DEINES ist. RunOnes erste
Fassung fragte „ist abgeschlossen" statt „haelt es jemand
anders" — damit haette, wer ordentlich abschliesst, nie mehr
ausliefern koennen. Dafuer gibt es `fremd`.
* Es VERFAELLT nach zwei Stunden, und dass da jemand war, steht
beim Uebernehmen dabei.
* Es blockiert NICHT, wenn es selbst unlesbar ist — dritter
Ausgang, kein Stillstand.
* Notausgang: SCHLOSS_ZWANG=ja git commit …
DAS PROBLEM, AN DEM RUNONE HAENGT, IST HIER GELOEST. Dort faellt die
Kennung im Zweifel auf den Systembenutzer zurueck, und zwei
Claudian-Sitzungen laufen BEIDE als `claudian` — die Sicherung griff
ausgerechnet zwischen den zwei Faellen nicht, fuer die sie gebaut
wurde. Deshalb ist dort `export ARBEITER=…` Pflicht, und Pflicht
heisst: man vergisst es.
Hier steht `CLAUDE_CODE_SESSION_ID` in jeder Sitzung und ist je
Sitzung verschieden (nachgesehen, 36 Zeichen UUID). Zwei Sitzungen
auf demselben Windows-Benutzer unterscheiden sich damit von selbst,
ohne dass jemand etwas tun muss. Reihenfolge: ARBEITER, dann
Sitzungskennung, dann Benutzername MIT Warnung.
NIEMAND MUSS DARAN DENKEN:
* die beiden Stempelwerkzeuge nehmen es selbst und geben es selbst
frei — auch nach einem Absturz und bei Strg+C (wie `bauen.sh` im
Shop; eines, an das man denken muss, wird vergessen und ab da
umgangen)
* `tools/git-haken/pre-commit` bricht jeden Commit ab, solange
jemand ANDERS das Schloss haelt. Das ist die Stelle, die heute
gefehlt hat: `git add -A` ist der Griff, den man ohne Nachdenken
macht, und gegen einen Reflex hilft keine Regel auf Papier.
* `core.hooksPath` statt `.git/hooks` — letzteres ist nicht
versioniert und waere nach einem Klon genau dann leer, wenn es
gebraucht wird.
GEPRUEFT, server/pruef-arbeitsschloss.mjs: 33 Pruefungen, 0 Fehler —
in einem WEGWERF-Verzeichnis mit eigenem git, damit kein echtes
Schloss angefasst wird. Darunter am echten git:
ohne Schloss committen -> geht (0)
mit dem EIGENEN Schloss -> geht (0)
mit einem FREMDEN Schloss -> bricht ab (1), nennt wer und warum
und es stehen genau ZWEI Commits da, nicht drei
SCHLOSS_ZWANG=ja -> kommt vorbei
ohne das Schloss-Werkzeug -> laesst durch
Dazu: verfallenes Schloss laesst durch, mit laengerer Frist blockt
dasselbe Schloss wieder (Gegenprobe), unlesbarer Inhalt und
unlesbarer Zeitstempel blockieren nicht.
Portnummern nach der neuen Pruefdatei nachgemessen: Pruefbereich bis
5415, 462 Nummern, 0 Kollisionen. pruef-struktur 75/0,
pruef-fingermass 5/0, pruef-ports 10/0.
In DEPLOY.md steht es jetzt an erster Stelle — eine Sicherung, von
der nur der weiss, der sie gebaut hat, ist die erste, die umgangen
wird.
Co-Authored-By: Claude Opus 5 <[email protected]>
257 lines
12 KiB
JavaScript
257 lines
12 KiB
JavaScript
/* =====================================================================
|
|
pruef-arbeitsschloss.mjs — hält das Schloss, was es verspricht?
|
|
|
|
WARUM ES DAS SCHLOSS GIBT (01.10.2026): Zwei Claude-Sitzungen
|
|
arbeiteten gleichzeitig in DogiHompage, ohne voneinander zu wissen.
|
|
Beide haben `git add -A` benutzt, beide den Stempellauf. Zweimal
|
|
ist es nur gutgegangen.
|
|
|
|
WARUM ES DIESE PRÜFUNG GIBT: Ein Schloss ist eine Sicherung, und
|
|
eine Sicherung, die man nie ausgelöst hat, ist eine Hoffnung. Die
|
|
teuren Fehler liegen bei einem Schloss alle in derselben Richtung —
|
|
es blockiert zu viel:
|
|
|
|
* Es blockiert das EIGENE Arbeiten -> wird abgeschafft.
|
|
* Es blockiert nach einem Absturz dauerhaft -> wird umgangen.
|
|
* Es blockiert, wenn es selbst kaputt ist -> wird entfernt.
|
|
|
|
Deshalb prüft diese Datei NICHT nur, dass es zuschlägt, sondern
|
|
vor allem, dass es in genau diesen drei Fällen DURCHLÄSST.
|
|
|
|
---------------------------------------------------------------------
|
|
ALLES IN EINEM WEGWERF-VERZEICHNIS
|
|
|
|
Die Prüfung legt ein eigenes kleines git-Verzeichnis an, kopiert
|
|
das Schloss und den Haken hinein und spielt dort. Das echte
|
|
`.arbeitsschloss` wird nicht angefasst — sonst nähme eine Prüfung
|
|
jemandem sein Schloss weg, und das wäre genau der Schaden, den sie
|
|
verhindern soll.
|
|
|
|
Aufrufen mit: node server/pruef-arbeitsschloss.mjs
|
|
===================================================================== */
|
|
|
|
import { mkdtempSync, rmSync, mkdirSync, copyFileSync, writeFileSync,
|
|
readFileSync, existsSync, chmodSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
|
|
let ok_ = 0, fehl = 0, offen = 0;
|
|
const ok = (b, text) => { if (b) { ok_++; console.log(" ok " + text); }
|
|
else { fehl++; console.log(" FEHL " + text); } };
|
|
const unklar = (text) => { offen++; console.log(" -- " + text); };
|
|
|
|
const HIER = dirname(fileURLToPath(import.meta.url));
|
|
const WURZEL = join(HIER, "..");
|
|
const SCHLOSS = join(WURZEL, "tools", "arbeitsschloss.mjs");
|
|
const HAKEN = join(WURZEL, "tools", "git-haken", "pre-commit");
|
|
|
|
/* ---------------------------------------------------------------------
|
|
1. Steht überhaupt alles da?
|
|
--------------------------------------------------------------------- */
|
|
console.log("\n=== 1. Die Teile ===");
|
|
ok(existsSync(SCHLOSS), "tools/arbeitsschloss.mjs ist da");
|
|
ok(existsSync(HAKEN), "tools/git-haken/pre-commit ist da");
|
|
|
|
/* DAS SCHLOSS DARF NICHT IM VERLAUF LANDEN. Committet wäre es eine
|
|
Behauptung von gestern: Nach einem Klon stünde dort, dass jemand
|
|
arbeitet, der längst fertig ist — und dann blockiert es, bis es
|
|
verfällt. */
|
|
{
|
|
const ignoriert = readFileSync(join(WURZEL, ".gitignore"), "utf8");
|
|
ok(/^\.arbeitsschloss$/m.test(ignoriert),
|
|
".arbeitsschloss steht in .gitignore (sonst waere es ein Schloss von gestern)");
|
|
}
|
|
|
|
/* DER HAKEN MUSS AUCH GEFUNDEN WERDEN. `.git/hooks` wird nicht
|
|
versioniert; ohne `core.hooksPath` liegt der Haken im Baum und tut
|
|
nichts. Das ist die Sorte Sicherung, die aussieht, als wäre sie da. */
|
|
{
|
|
let pfad = "";
|
|
try {
|
|
pfad = execFileSync("git", ["config", "--get", "core.hooksPath"],
|
|
{ cwd: WURZEL, encoding: "utf8" }).trim();
|
|
} catch { pfad = ""; }
|
|
ok(pfad === "tools/git-haken",
|
|
`core.hooksPath zeigt auf den Haken ("${pfad || "nicht gesetzt"}")`
|
|
+ (pfad === "tools/git-haken" ? "" : " — node tools/arbeitsschloss.mjs einrichten"));
|
|
}
|
|
|
|
/* DIE STEMPELWERKZEUGE NEHMEN ES SELBST. Abgeleitet aus dem
|
|
Quelltext, nicht angenommen: Ein Schloss, das man von Hand nehmen
|
|
muss, wird vergessen — und dann umgangen. */
|
|
for (const w of ["workspace-stempel.mjs", "seiten-stempel.mjs"]) {
|
|
const t = readFileSync(join(WURZEL, "tools", w), "utf8");
|
|
ok(/schlossKurz\(/.test(t) && /arbeitsschloss\.mjs/.test(t),
|
|
`tools/${w} nimmt das Schloss selbst`);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
2. Wer bin ich?
|
|
--------------------------------------------------------------------- */
|
|
console.log("\n=== 2. Die Kennung ===");
|
|
const { werIch } = await import("file://" + SCHLOSS.replace(/\\/g, "/"));
|
|
{
|
|
const alt = process.env.ARBEITER;
|
|
delete process.env.ARBEITER;
|
|
const ohneName = werIch();
|
|
ok(ohneName.woher === "Sitzungskennung" && /^claude-[0-9a-f]{8}$/.test(ohneName.name),
|
|
`ohne ARBEITER kommt die Sitzungskennung ("${ohneName.name}")`);
|
|
/* DAS IST DER PUNKT, AN DEM RUNONE HÄNGT: Dort fällt es auf den
|
|
Systembenutzer zurück, und zwei Sitzungen desselben Benutzers
|
|
sehen damit gleich aus — die Sicherung griff ausgerechnet
|
|
zwischen den zwei Fällen nicht, für die sie gebaut wurde. */
|
|
ok(ohneName.name !== process.env.USERNAME,
|
|
"und nicht der Benutzername — zwei Sitzungen desselben Benutzers"
|
|
+ " muessen sich unterscheiden");
|
|
process.env.ARBEITER = "probe-name";
|
|
ok(werIch().name === "probe-name", "ein ausdrueckliches ARBEITER gewinnt");
|
|
if (alt === undefined) delete process.env.ARBEITER; else process.env.ARBEITER = alt;
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
3. Das Verhalten — im Wegwerf-Verzeichnis
|
|
--------------------------------------------------------------------- */
|
|
console.log("\n=== 3. Nehmen, sehen, freigeben ===");
|
|
const spiel = mkdtempSync(join(tmpdir(), "schlossprobe-"));
|
|
mkdirSync(join(spiel, "tools", "git-haken"), { recursive: true });
|
|
copyFileSync(SCHLOSS, join(spiel, "tools", "arbeitsschloss.mjs"));
|
|
copyFileSync(HAKEN, join(spiel, "tools", "git-haken", "pre-commit"));
|
|
try { chmodSync(join(spiel, "tools", "git-haken", "pre-commit"), 0o755); } catch { /* egal */ }
|
|
|
|
const SCHLOSSDATEI = join(spiel, ".arbeitsschloss");
|
|
const ICH = "claude-probe01";
|
|
const ANDERER = "claude-probe02";
|
|
|
|
/** Ruft das Schloss im Spielverzeichnis auf. */
|
|
function rufen(befehl, { wer = ICH, grund = "", verfall } = {}) {
|
|
const e = spawnSync(process.execPath,
|
|
[join(spiel, "tools", "arbeitsschloss.mjs"), befehl, grund],
|
|
{ cwd: spiel, encoding: "utf8",
|
|
env: { ...process.env, ARBEITER: wer,
|
|
...(verfall ? { SCHLOSS_VERFALL: String(verfall) } : {}) } });
|
|
return { stand: e.status, aus: (e.stdout || "") + (e.stderr || "") };
|
|
}
|
|
|
|
const schlossSetzen = (wer, vorMinuten = 0, was = "etwas") => writeFileSync(SCHLOSSDATEI,
|
|
`wer=${wer}\nseit=${new Date(Date.now() - vorMinuten * 60000).toISOString()}\n`
|
|
+ `was=${was}\nrechner=probe\n`);
|
|
|
|
ok(rufen("ansehen").aus.trim() === "frei", "ein leeres Verzeichnis ist frei");
|
|
|
|
{
|
|
const r = rufen("nehmen", { grund: "Probe" });
|
|
ok(r.stand === 0 && existsSync(SCHLOSSDATEI), `nehmen legt es an (${r.stand})`);
|
|
ok(/Sitzungskennung|ARBEITER/.test(r.aus), "und sagt, woher die Kennung kommt");
|
|
}
|
|
ok(rufen("ansehen").stand === 1, "danach meldet ansehen: belegt (Stand 1)");
|
|
ok(rufen("nehmen").stand === 0, "zweimal nehmen vom selben ist in Ordnung");
|
|
|
|
/* ---- DER FALL, DER ALLES ENTSCHEIDET -------------------------------
|
|
`fremd` muss zwischen „ich halte es" und „ein anderer haelt es"
|
|
unterscheiden. RunOnes erste Fassung konnte das nicht und hat damit
|
|
genau das richtige Verhalten bestraft. */
|
|
ok(rufen("fremd").stand === 0, "fremd: mein EIGENES Schloss blockiert mich nicht");
|
|
schlossSetzen(ANDERER);
|
|
ok(rufen("fremd").stand === 1, "fremd: ein FREMDES blockiert (Stand 1)");
|
|
ok(rufen("nehmen").stand === 1, "nehmen prallt an einem fremden ab");
|
|
{
|
|
const r = rufen("freigeben");
|
|
ok(r.stand === 1 && /gehört/.test(r.aus),
|
|
"und ein fremdes gibt man nicht einfach frei");
|
|
}
|
|
|
|
/* ---- ES VERFÄLLT. Sonst blockiert ein Absturz dauerhaft. ---------- */
|
|
schlossSetzen(ANDERER, 180);
|
|
ok(rufen("fremd").stand === 0, "ein verfallenes fremdes Schloss blockiert NICHT mehr");
|
|
{
|
|
const r = rufen("nehmen");
|
|
ok(r.stand === 0 && /verfallen/.test(r.aus),
|
|
"es wird uebernommen — und dass da jemand war, steht dabei");
|
|
}
|
|
schlossSetzen(ANDERER, 180);
|
|
ok(rufen("fremd", { verfall: 400 }).stand === 1,
|
|
"Gegenprobe: mit laengerer Frist gilt dasselbe Schloss wieder (SCHLOSS_VERFALL)");
|
|
|
|
/* ---- UNLESBAR IST NICHT BELEGT ------------------------------------
|
|
Ein kaputtes Schloss darf die Arbeit nicht anhalten. */
|
|
writeFileSync(SCHLOSSDATEI, "das ist kein Schloss\n");
|
|
{
|
|
const r = rufen("fremd");
|
|
ok(r.stand === 0, `ein unbrauchbarer Inhalt blockiert nicht (${r.stand})`);
|
|
}
|
|
writeFileSync(SCHLOSSDATEI, `wer=${ANDERER}\nseit=voelliger-unsinn\nwas=x\n`);
|
|
ok(rufen("fremd").stand === 0,
|
|
"und ein unlesbarer Zeitstempel gilt als verfallen, nicht als eben genommen");
|
|
|
|
/* ---- Freigeben ---------------------------------------------------- */
|
|
schlossSetzen(ICH);
|
|
ok(rufen("freigeben").stand === 0 && !existsSync(SCHLOSSDATEI),
|
|
"das eigene gibt man frei, und danach ist die Datei weg");
|
|
ok(rufen("freigeben").stand === 0, "zweimal freigeben ist kein Fehler");
|
|
|
|
/* ---------------------------------------------------------------------
|
|
4. Der git-Haken — am echten git, nicht an der Behauptung
|
|
--------------------------------------------------------------------- */
|
|
console.log("\n=== 4. Der Haken am echten git ===");
|
|
{
|
|
const git = (...a) => spawnSync("git", a, { cwd: spiel, encoding: "utf8",
|
|
env: { ...process.env, ARBEITER: ICH } });
|
|
git("init", "-q");
|
|
git("config", "user.email", "[email protected]");
|
|
git("config", "user.name", "Probe");
|
|
git("config", "core.hooksPath", "tools/git-haken");
|
|
writeFileSync(join(spiel, "a.txt"), "eins\n");
|
|
git("add", "-A");
|
|
const erst = git("commit", "-q", "-m", "erster");
|
|
ok(erst.status === 0, `ohne Schloss laesst der Haken committen (${erst.status})`);
|
|
|
|
writeFileSync(join(spiel, "a.txt"), "zwei\n");
|
|
schlossSetzen(ICH);
|
|
git("add", "-A");
|
|
const eigen = git("commit", "-q", "-m", "mit eigenem Schloss");
|
|
ok(eigen.status === 0,
|
|
`mit dem EIGENEN Schloss ebenfalls (${eigen.status}) — sonst waere ordentliches`
|
|
+ " Abschliessen bestraft");
|
|
|
|
writeFileSync(join(spiel, "a.txt"), "drei\n");
|
|
schlossSetzen(ANDERER, 0, "arbeitet gerade");
|
|
git("add", "-A");
|
|
const fremd = git("commit", "-q", "-m", "darf nicht entstehen");
|
|
const text = (fremd.stdout || "") + (fremd.stderr || "");
|
|
ok(fremd.status === 1, `mit einem FREMDEN Schloss bricht der Commit ab (${fremd.status})`);
|
|
ok(/arbeitet gerade jemand anders/.test(text), "und sagt, warum");
|
|
ok(/claude-probe02/.test(text), "und WER es ist");
|
|
|
|
/* UND DIE ZAHL DER COMMITS BEWEIST ES. Eine Meldung allein könnte
|
|
auch bei einem erfolgreichen Commit erscheinen. */
|
|
const anzahl = Number(spawnSync("git", ["rev-list", "--count", "HEAD"],
|
|
{ cwd: spiel, encoding: "utf8" }).stdout.trim());
|
|
ok(anzahl === 2, `es stehen genau zwei Commits da, nicht drei (${anzahl})`);
|
|
|
|
/* DER NOTAUSGANG MUSS GEHEN. Ein Riegel ohne Notausgang wird
|
|
ausgebaut, nicht benutzt. */
|
|
const zwang = spawnSync("git", ["commit", "-q", "-m", "mit Zwang"],
|
|
{ cwd: spiel, encoding: "utf8",
|
|
env: { ...process.env, ARBEITER: ICH, SCHLOSS_ZWANG: "ja" } });
|
|
ok(zwang.status === 0, `SCHLOSS_ZWANG=ja kommt vorbei (${zwang.status})`);
|
|
|
|
/* UND OHNE DAS SCHLOSS-WERKZEUG DARF ER NICHT BLOCKIEREN: Wer den
|
|
Haken in ein Verzeichnis ohne Schloss mitnimmt, soll arbeiten
|
|
koennen. */
|
|
rmSync(join(spiel, "tools", "arbeitsschloss.mjs"));
|
|
schlossSetzen(ANDERER);
|
|
writeFileSync(join(spiel, "a.txt"), "vier\n");
|
|
git("add", "-A");
|
|
const ohneWerkzeug = git("commit", "-q", "-m", "ohne Werkzeug");
|
|
ok(ohneWerkzeug.status === 0,
|
|
`ohne das Schloss-Werkzeug laesst der Haken durch (${ohneWerkzeug.status})`);
|
|
}
|
|
|
|
try { rmSync(spiel, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
|
|
|
|
console.log(`\n${ok_} Pruefungen, ${fehl} Fehler, ${offen} nicht nachsehbar`);
|
|
console.log(fehl ? "NICHT IN ORDNUNG\n" : "ALLES IN ORDNUNG\n");
|
|
process.exit(fehl ? 1 : 0);
|