pi-system/extensions/rule-enforcer.ts
Raimund Bauer aa1539c744 fix/extensions: rule-enforcer erzwingt Invariante — Orchestrator nie idle während Subagent läuft
Root Cause (log-belegt, 36,6-min-Lücke 15:25–16:01): watch_subagents ist
ein Polling-Tool; die Überwachung lebt nur solange das LLM es neu aufruft.
Eine User-Zwischenfrage riss die Schleife ab, der Orchestrator ging idle.
Der passive Alert-File-Pfad (nur in before_agent_start gelesen) feuert bei
idle nie → Orchestrator schlief 36 min bis der Mensch tippte.

Fix: 30s-Check erkennt laufende Subagenten per Prozessbaum (lebt ein pi-Kind
unter dem tmux-Pane?), nicht per Keyword. Idle + laufender Subagent → aktive
Weckung via pi.sendUserMessage() (löst garantiert einen Turn aus) + ui.notify.
Idle-Erkennung zeitbasiert (45s, > 30s Pollintervall), ctx-unabhängig.

Verifiziert: Syntax, Modul-Load, Handler-Registrierung, Prompt-Injection,
W06, Prozessbaum-Erkennung an echten Sessions. NICHT verifiziert: Live-Weckpfad
(erfordert Orchestrator-Test). Plan: doku/fix-plan-orchestrator-wecker-v2026-06-02-18-19.md
2026-06-02 18:33:02 +02:00

311 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Rule Enforcer Extension
*
* Mechanismen:
*
* 1. before_agent_start — injiziert Kern-Regeln in jeden System-Prompt-Turn
* + Selbstkorrektur wenn letzter Turn W06 verletzt hat
* + Subagenten-Lage aus Alert-File (passiver Pfad)
*
* 2. message_end — scannt Pi's Ausgabe auf W06-Deliberations-Muster,
* setzt Flag für nächsten before_agent_start
*
* 3. setInterval (30s) — erzwingt die Invariante:
* "Solange ein Subagent läuft, darf der Orchestrator nicht idle sein."
* Erkennt laufende Subagenten per PROZESSBAUM (lebt ein `pi`-Kind unter dem
* tmux-Pane?), nicht per Keyword. Ist der Orchestrator idle, obwohl ein
* Subagent läuft, wird er AKTIV via pi.sendUserMessage() geweckt — das löst
* garantiert einen Turn aus. Der passive Alert-File-Pfad allein reicht nicht,
* weil before_agent_start bei einem idle Orchestrator nie feuert.
*
* Nur aktiv wenn PI_ORCHESTRATOR=1.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import * as fs from "node:fs";
import { execSync } from "node:child_process";
const ORCHESTRATOR_AKTIV = !!process.env.PI_ORCHESTRATOR;
const ALERT_FILE = "/tmp/.pi-subagent-alert";
const CHECK_INTERVAL_MS = 30_000;
// Idle = seit so langer Zeit kein Pi-Event. 45s > 30s watch_subagents-Pollintervall,
// damit aktive Überwachung nicht fälschlich als idle gilt.
const IDLE_SCHWELLE_MS = 45_000;
// Höchstens eine aktive Weckung in diesem Abstand.
const WECK_MIN_ABSTAND_MS = 60_000;
const SUBAGENT_PFAD_MARKER = "/.pi/subagents/";
// ---------------------------------------------------------------------------
// Kern-Regeln — werden in jeden System-Prompt-Turn injiziert
// ---------------------------------------------------------------------------
const KERN_REGELN = `
## PFLICHT-REGELN — gelten für JEDE Antwort ohne Ausnahme
**W06** Kein Deliberieren: Niemals "Let me", "Actually", "I should", "Ich sollte", "Das liegt daran", "Weil ich" in Antworten ausgeben. Nur Ergebnis oder Entscheidung.
**W07** Subagenten nicht aufgeben: Bei Problemen Benutzer kurz informieren, auf Anweisung warten. Nie eigenständig auf direkte Ausführung umschwenken.
**W09** sleep verboten: Stattdessen watch_subagents nutzen oder Benutzer informieren.
**W10** Subagent nie doppelt starten: Einmal starten, dann watch_subagents abwarten.
**WATCH** watch_subagents SOFORT nach jeder Antwort erneut aufrufen — permanent, ohne Ausnahme. Solange ein Subagent läuft, darfst du NIE idle werden.
`.trim();
// ---------------------------------------------------------------------------
// W06-Deliberations-Muster
// ---------------------------------------------------------------------------
const DELIBERATIONS_MUSTER: RegExp[] = [
/\bLet me\b/,
/\bActually,?\s/,
/\bI should\b/,
/\bI need to\b/,
/\bI'll check\b/,
/\bI'll look\b/,
/\bLet me check\b/,
/\bLet me look\b/,
/\bLet me verify\b/,
/\bLet me first\b/,
/\bHmm,?\s/i,
/\bWait,?\s/,
/\bIch sollte\b/,
/\bLass mich\b/,
/\bDas liegt daran\b/,
/\bWeil ich\b/,
/\bIch muss\b/,
/\bIch werde\b.*\bprüfen\b/,
];
function hatDeliberation(text: string): boolean {
return DELIBERATIONS_MUSTER.some((p) => p.test(text));
}
function textAusNachricht(message: any): string {
const content = message?.content;
if (!Array.isArray(content)) return "";
return content
.filter((c: any) => c.type === "text")
.map((c: any) => String(c.text ?? ""))
.join(" ");
}
// ---------------------------------------------------------------------------
// Alert-File (passiver Pfad)
// ---------------------------------------------------------------------------
function leseAlerts(): string | null {
try {
if (!fs.existsSync(ALERT_FILE)) return null;
const content = fs.readFileSync(ALERT_FILE, "utf-8").trim();
if (!content) return null;
fs.writeFileSync(ALERT_FILE, "");
return content;
} catch {
return null;
}
}
function schreibeAlert(text: string): void {
try {
fs.writeFileSync(ALERT_FILE, text);
} catch {}
}
// ---------------------------------------------------------------------------
// Laufende Subagenten per PROZESSBAUM erkennen
//
// pane_current_command ist unbrauchbar (meldet "bash", obwohl pi als Kind läuft).
// Stattdessen: für jede tmux-Session unter ~/.pi/subagents/ prüfen, ob unter dem
// Pane-Shell-PID ein lebender `pi`-Prozess hängt.
// ---------------------------------------------------------------------------
interface LaufenderSubagent {
name: string;
dialog: string | null; // Pane-Auszug, falls ein Bestätigungs-Dialog erkennbar ist
}
function laeuftPiUnter(panePid: number): boolean {
try {
const kids = execSync(`pgrep -P ${panePid} 2>/dev/null || true`, {
encoding: "utf-8",
timeout: 1500,
}).trim();
const pids = kids.split(/\s+/).filter(Boolean);
for (const k of pids) {
const n = Number(k);
if (!Number.isInteger(n)) continue;
const comm = execSync(`ps -o comm= -p ${n} 2>/dev/null || true`, {
encoding: "utf-8",
timeout: 1000,
}).trim();
if (comm === "pi") return true;
// pi könnte eine Ebene tiefer laufen (z.B. via node-Wrapper)
if (laeuftPiUnter(n)) return true;
}
return false;
} catch {
return false;
}
}
function laufendeSubagenten(): LaufenderSubagent[] {
try {
const raw = execSync(
"tmux list-panes -a -F '#{session_name}::#{pane_pid}::#{pane_current_path}' 2>/dev/null || true",
{ encoding: "utf-8", timeout: 2500 },
).trim();
if (!raw) return [];
const ergebnis: LaufenderSubagent[] = [];
const gesehen = new Set<string>();
for (const line of raw.split("\n")) {
const teile = line.split("::");
if (teile.length < 3) continue;
const name = teile[0].trim();
const panePid = Number(teile[1].trim());
const path = teile[2].trim();
if (!name || gesehen.has(name)) continue;
if (!path.includes(SUBAGENT_PFAD_MARKER)) continue;
if (!Number.isInteger(panePid)) continue;
if (!laeuftPiUnter(panePid)) continue; // Pi nicht (mehr) aktiv ⇒ kein laufender Subagent
gesehen.add(name);
// Optional: Dialog-Inhalt zur Anreicherung der Weckmeldung
let dialog: string | null = null;
try {
const pane = execSync(
`tmux capture-pane -t '${name}' -p 2>/dev/null | tail -8`,
{ encoding: "utf-8", timeout: 1000 },
).trim();
if (
/Erlauben\?|→ Yes|Überschreiben\?|ACHTUNG:|Bestätigung/.test(pane)
) {
dialog = pane;
}
} catch {}
ergebnis.push({ name, dialog });
}
return ergebnis;
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// Extension
// ---------------------------------------------------------------------------
export default function (pi: ExtensionAPI) {
if (!ORCHESTRATOR_AKTIV) return;
let letzterVerstoss: string | null = null;
let letzterCtx: any = null;
let letzteAktivitaet = Date.now();
let letzteWeckung = 0;
function markAktiv(ctx?: any): void {
letzteAktivitaet = Date.now();
if (ctx) letzterCtx = ctx;
}
// ── 30s Background-Check: Invariante erzwingen ───────────────────────────
const interval = setInterval(() => {
const subagenten = laufendeSubagenten();
if (subagenten.length === 0) return; // kein laufender Subagent ⇒ idle ist erlaubt
const dialoge = subagenten.filter((s) => s.dialog);
const beschreibung = subagenten
.map(
(s) =>
`${s.name}${s.dialog ? " — WARTET AUF BESTÄTIGUNG" : " — läuft"}`,
)
.join("\n");
// Passiver Pfad: greift, falls der Orchestrator gerade in einem Turn ist
schreibeAlert(
`[30s-Check] Laufende Subagenten — Orchestrator muss überwachen:\n${beschreibung}`,
);
// Idle-Erkennung: primär zeitbasiert (ctx-unabhängig)
const idleMs = Date.now() - letzteAktivitaet;
if (idleMs < IDLE_SCHWELLE_MS) return; // Orchestrator aktiv ⇒ nichts tun
// Zusätzliches Veto, falls ctx zuverlässig "beschäftigt" meldet
try {
if (letzterCtx && typeof letzterCtx.isIdle === "function") {
if (letzterCtx.isIdle() === false) return;
}
} catch {}
const jetzt = Date.now();
if (jetzt - letzteWeckung < WECK_MIN_ABSTAND_MS) return;
letzteWeckung = jetzt;
const dringlich = dialoge.length > 0;
const dialogText = dialoge
.map((d) => `--- ${d.name} ---\n${d.dialog}`)
.join("\n");
try {
letzterCtx?.ui?.notify?.(
dringlich
? "🚨 Subagent wartet auf Bestätigung — Orchestrator wird geweckt."
: "⏱️ Subagent läuft, Orchestrator war idle — wird geweckt.",
"warning",
);
} catch {}
try {
pi.sendUserMessage(
`🚨 [Auto-Weckung] Es läuft mindestens ein Subagent, aber du warst idle. ` +
`Das ist nicht erlaubt, solange Subagenten laufen.\n${beschreibung}\n` +
(dringlich
? `\nEin Subagent wartet auf eine Bestätigung:\n${dialogText}\n→ Sofort watch_subagents aufrufen, den Dialog prüfen und nach Regel bestätigen ODER den Benutzer informieren.\n`
: ``) +
`→ Nimm die watch_subagents-Überwachung sofort wieder auf und halte sie, bis ALLE Subagenten fertig sind.`,
);
} catch {}
}, CHECK_INTERVAL_MS);
pi.on("session_shutdown", async () => {
clearInterval(interval);
});
// ── Aktivitäts-Tracking für die zeitbasierte Idle-Erkennung ──────────────
pi.on("turn_start", async (_e, ctx) => markAktiv(ctx));
pi.on("turn_end", async (_e, ctx) => markAktiv(ctx));
pi.on("message_start", async (_e, ctx) => markAktiv(ctx));
pi.on("message_update", async (_e, ctx) => markAktiv(ctx));
pi.on("tool_execution_start", async (_e, ctx) => markAktiv(ctx));
pi.on("tool_execution_end", async (_e, ctx) => markAktiv(ctx));
// ── message_end: W06-Scan + Aktivität ────────────────────────────────────
pi.on("message_end", async (event, ctx) => {
markAktiv(ctx);
const text = textAusNachricht(event.message);
if (hatDeliberation(text)) {
letzterVerstoss = "W06 (Internes Deliberieren)";
}
});
// ── before_agent_start: System-Prompt-Injection ──────────────────────────
pi.on("before_agent_start", async (event, ctx) => {
markAktiv(ctx);
let zusatz = `\n\n---\n${KERN_REGELN}`;
if (letzterVerstoss) {
zusatz += `\n\n⚠ REGELVERSTOSS erkannt (${letzterVerstoss}): Diese Antwort OHNE Gedankenkommentar. Nur Ergebnis oder Entscheidung.`;
letzterVerstoss = null;
}
const alerts = leseAlerts();
if (alerts) {
zusatz += `\n\n🚨 SUBAGENT-LAGE:\n${alerts}\n→ watch_subagents aufrufen und die Überwachung halten, bis alle Subagenten fertig sind.`;
}
return { systemPrompt: event.systemPrompt + zusatz };
});
}