Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/mla-next/VERIFY.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,51 @@ unabhängig reproduziert).
**Task-5-Frischlauf (2026-09-30, auf Handoff-Stand):** `check-versions.sh` ok · `dart format` 123 Dateien 0 geändert · `flutter analyze` 0 Findings · `flutter test` +208 · additional/python 53 OK · GTK-Token-Gate 10 OK · la_core format 0 geändert / analyze clean / +61.

**Rückfallplan.** `git revert` der #91-Commits (`f8a080e` … Handoff-Commit) genügt: neue Dateien (TOKENS.md, tokens.css, tokens-dark.css, test_tokens.py, Plan-Datei) plus kleine Änderungen (mla_app.py, build.yml, VERIFY.md); kein Datenpfad, keine Unit, kein Packaging, polkit-Trinität unberührt.
## Handoff #110-A — Registry-Race-Härtung (Nachtlauf 2026-09-30, abgeschlossen 2026-10-01)

**Status:** Pool-Position A aus Issue #110 (Registry-Race-Härtung la_core) umgesetzt auf Branch `night/pool-a` (Basis-SHA `506eb88`). Umsetzung aus dem Nachtlauf (Automation) vom 2026-09-30 (`b13bdd7`, 2 Dateien, +298/−26); Review-Fix `530ea0f`, Handoff-Sektion, Gate-Frischlauf, RED-Belege, Ledger und Push/PR am 2026-10-01 interaktiv ergänzt.

**Failing-Test/Fixture.** `packages/la_core/test/module_registry_ordering_test.dart` (neu, 6 Fälle) mit **echten `Completer`-Gates** (keine Fake-Async), gemäß Repo-Lektion. Testnamen:

1. `activate nach laufendem deactivate derselben ID endet started` (geordnetes Last-Wins)
2. `deactivate nach laufendem activate derselben ID endet stopped` (Gegenrichtung)
3. `activate eines Abhaengigen startet den Dep nach dessen laufendem deactivate neu, statt ueber ihm zu starten`
4. `gestartetes Modul ueber inzwischen gestopptem Dep wird zurueckgerollt und wirft`
5. `paralleles activate zweier Abhaengiger startet den gemeinsamen Dep genau einmal` (Bestandsverhalten / Single-Flight)
6. `deactivateAll stoppt ein Modul im Uebergang, statt es zu ueberspringen (Last-Wins)` (Regressionstest zum Review-Fix `530ea0f`)

**RED-Belege.**
- Gegen Basis `506eb88` (nur die Testdatei, `module_registry.dart` auf Basis getauscht): `+1 -4: Some tests failed.` → Tests 1–4 rot, Test 5 Basis-Verhalten grün.
- Gegen `b13bdd7` (vor dem Review-Fix): Test 6 rot — `Expected: ModuleState.stopped / Actual: ModuleState.started`.

**Basis-Entscheidungen / gewählte Semantik.**
- `_Inflight` trägt die Richtung (`_Direction {activate, deactivate}`); nur gleichgerichtete Aufrufe teilen sich eine Future (Single-Flight). Ein Gegenrichtungs-Aufruf reiht sich hinter den laufenden Vorgang ein — geordnetes Last-Wins.
- Re-Check der Dep-States nach jedem `await` (Requires-Invariante): Ein Start über einem inzwischen gestoppten Dep rollt sich zurück (`_rollbackStart`) und wirft `ModuleRegistryError`.
- `_inflight` wird ausschließlich von `_run` geräumt (`identical`-Guard).
- **Review-Fix:** `deactivateAll`-Guard von `_states[id] == started` auf `_states[id] == started || _inflight.containsKey(id)` erweitert, damit ein Modul im `stopping`-Übergang (mit eingequeuter Gegenrichtungs-Aktivierung) nicht übersprungen wird (Last-Wins).

**Gates — tatsächlich ausgeführt (2026-10-01, frisch auf `530ea0f`):**

| Gate | Ausgabe |
|---|---|
| la_core `dart analyze` | `No issues found!` / Exit 0 |
| la_core `dart format --output=none --set-exit-if-changed .` | `Formatted 25 files (0 changed)` / Exit 0 |
| la_core `dart test` | `+67: All tests passed!` (61 Basis + 5 ordering + 1 Fix-Regression) / Exit 0 |
| `bash tool/check-versions.sh` | `version 0.8.0 is consistent` / Exit 0 |
| Root `dart format --output=none --set-exit-if-changed lib test` | `Formatted 123 files (0 changed)` / Exit 0 |
| `flutter analyze` | `No issues found!` / Exit 0 |
| `flutter test` | `+208: All tests passed!` / Exit 0 |
| `additional/python` unittest discover | `Ran 53 tests` / `OK` |

**Rote/übersprungene Gates.** Keine roten. Übersprungen: `build-deb.sh` (kein Packaging-Bezug; CI baut beim PR), Branch-CI (läuft mit dem PR), GTK-Token-Gate (#91-Dateien, nicht im Scope), manuelle Gate-0-Checks (bleiben Basti).

**Reviewer (Dual-Review, 2 parallele Subagenten auf `506eb88..b13bdd7`, 2026-10-01).**
- **A (Korrektheit): CHANGES_REQUIRED** — 1 major: `deactivateAll` übersprang Module im `stopping`-Übergang und verletzte damit Last-Wins (reproduzierbares Interleaving, Regression gegen Basis `506eb88`). → Fix `530ea0f` → **scoped Re-Review: ADDRESSED, keine neue Breakage** (Guard-Wirkung, Unreachability des settled-`stopped`-Falls und Testschärfe eigenständig nachgeprüft; Gegentest: Guard zurückgesetzt ⇒ Test 6 rot) ⇒ **APPROVED**.
- **B (Sicherheit/Scope/Spec): CHANGES_REQUIRED → nach Ergänzung dieser Sektion READY_FOR_PR** — einziges Finding war der noch fehlende Handoff-Abschnitt (Vertragspflicht), kein Code-Befund. Belegt: Scope exakt die 2 la_core-Dateien; öffentliche API-Fläche unverändert (nur neue Private); `lib/` nutzt `ModuleRegistry` nicht (einziger Konsument `test/hub_module_registry_test.dart`, kompatibel); keine Flutter-/`dart:io`-Kopplung; polkit-Trinität und Command-Queue unberührt; keine Secrets.

**Rückfallplan.** `git revert 530ea0f b13bdd7` genügt: 2 la_core-Dateien (1 Implementierung + 1 neuer Test), kein Produktionscode außerhalb la_core, keine Unit, kein Packaging, polkit-Trinität unberührt.

**Grenzen (bewusst offen).** `deactivateAll` deckt Module, die beim Aufruf `starting` sind (noch nicht in `_activationOrder`), weiterhin nicht ab — pre-existing, außerhalb des Diff-Scopes (Reviewer 1 + Fixer-Scopenotiz). Der Kommentar am erweiterten Guard nennt „stopping/starting"; erreichbar ist nur der `stopping`-Fall (Re-Review-Minor, reiner Kommentar-Wortlaut). Issue #110 Position C (Spawn-PATH-Fixierung) bleibt offen und braucht eine menschliche Sicherheitsentscheidung. Der Cross-ID-Race-Teil der #110-A-Checkbox ist über Test 3/5 abgedeckt (gemeinsamer Dep), nicht als eigener Cross-ID-Stresstest.

## Agenten-Handoff

Expand Down
163 changes: 136 additions & 27 deletions packages/la_core/lib/src/module_registry.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,34 @@
import 'dart:async';

import 'event_bus.dart';
import 'module_descriptor.dart';

enum ModuleState { registered, starting, started, stopping, stopped }

/// Richtung der unter [ModuleRegistry._inflight] angemeldeten Operation.
/// Nur gleichgerichtete Aufrufe teilen sich eine Future; Gegenrichtungen
/// laufen nacheinander (geordnetes Last-Wins, Issue #110).
enum _Direction { activate, deactivate }

/// Laufender Vorgang einer Modul-ID. Der Vorgang wird ueber [complete] bzw.
/// [fail] abgeschlossen; Nachfolger in Gegenrichtung warten auf [operation].
class _Inflight {
_Inflight(this.direction);

final _Direction direction;
final Completer<void> _done = Completer<void>();

Future<void> get operation => _done.future;

void complete() {
if (!_done.isCompleted) _done.complete();
}

void fail(Object error, StackTrace stackTrace) {
if (!_done.isCompleted) _done.completeError(error, stackTrace);
}
}

abstract interface class ModuleActivator {
Future<void> start(ModuleDescriptor module);
Future<void> stop(ModuleDescriptor module);
Expand Down Expand Up @@ -30,7 +56,12 @@ class ModuleRegistry {
final Map<String, ModuleDescriptor> _modules = {};
final Map<String, ModuleState> _states = {};
final Map<String, bool> _visible = {};
final Map<String, Future<void>> _inflight = {};

/// Laufende Operation je Modul-ID. Vorher lag hier nur eine Future, wodurch
/// ein `activate` eine laufende Deaktivierung still als eigenes Ergebnis
/// teilte und ein Modul ueber einem bereits gestoppten Abhaengigen starten
/// konnte (Issue #110).
final Map<String, _Inflight> _inflight = {};
final List<String> _activationOrder = [];
bool _validated = false;

Expand Down Expand Up @@ -90,24 +121,105 @@ class ModuleRegistry {
throw ModuleRegistryError('unknown module id: $id');
}
if (!_validated) validate();
return _inflight[id] ??= _activateSubtree(id);
return _enqueue(id, _Direction.activate);
}

/// Reiht eine Operation fuer [id] ein. Gleichgerichtete Aufrufe teilen sich
/// die laufende Future (Single-Flight); ein Gegenrichtungs-Aufruf wartet auf
/// den laufenden Vorgang und startet danach — geordnetes Last-Wins, damit
/// `activate` nach einem `deactivate` tatsaechlich wieder startet (Issue
/// #110).
Future<void> _enqueue(String id, _Direction direction) {
final existing = _inflight[id];
if (existing == null) {
final entry = _Inflight(direction);
_inflight[id] = entry;
unawaited(_run(id, direction, entry));
return entry.operation;
}
if (existing.direction == direction) return existing.operation;

final entry = _Inflight(direction);
_inflight[id] = entry;
unawaited(
existing.operation
// Das Ergebnis des Vorgaengers ist nur fuer die Reihenfolge
// relevant; ein Fehler dort gehoert seinem Aufrufer, nicht dem
// Nachfolger.
.then<void>((_) {}, onError: (Object _, StackTrace _) {})
.then((_) => _run(id, direction, entry)),
);
return entry.operation;
}

/// Fuehrt die angemeldete Operation aus und schliesst [entry] ab. Fehler
/// laufen auf die Future des Eintrags, damit der `unawaited`-Start in
/// [_enqueue] keinen unbehandelten Fehler erzeugt.
Future<void> _run(String id, _Direction direction, _Inflight entry) async {
try {
if (direction == _Direction.activate) {
await _activateSubtree(id);
} else {
await _deactivateWithDependents(id);
}
entry.complete();
} catch (error, stackTrace) {
entry.fail(error, stackTrace);
} finally {
// Nur den eigenen Eintrag raeumen: eine bereits eingereihte
// Gegenrichtung haengt unter demselben Key und wartet auf uns.
if (identical(_inflight[id], entry)) {
_inflight.remove(id);
}
}
}

Future<void> _activateSubtree(String id) async {
for (final depId in _dependenciesOf(id)) {
if (_states[depId] != ModuleState.started) {
await (_inflight[depId] ??= _activateSubtree(depId));
// Auch bei laufendem Vorgang anmelden: ist das eine Deaktivierung,
// reiht sich der Start dahinter ein und startet das Dep danach neu,
// statt still ueber einem gestoppten Dep zu starten (Issue #110).
final pending = _inflight[depId];
if (pending != null || _states[depId] != ModuleState.started) {
await _enqueue(depId, _Direction.activate);
}
_requireStarted(id, depId);
}
Comment on lines +186 to 187
if (_states[id] == ModuleState.started) return;
_states[id] = ModuleState.starting;
try {
await _activator.start(_modules[id]!);
_states[id] = ModuleState.started;
_activationOrder.add(id);
} finally {
_inflight.remove(id);
await _activator.start(_modules[id]!);
// Re-Check nach dem Await: waehrend des Starts kann ein Dep gestoppt
// worden sein. Dann nicht als started stehen bleiben, sondern den Start
// zurueckrollen und laut werfen (Requires-Invariante).
for (final depId in _dependenciesOf(id)) {
final pending = _inflight[depId];
final depWillStop =
pending != null && pending.direction == _Direction.deactivate;
if (_states[depId] != ModuleState.started || depWillStop) {
await _rollbackStart(id);
throw ModuleRegistryError(
'$id requires $depId, which is no longer started',
);
}
}
_states[id] = ModuleState.started;
_activationOrder.add(id);
}

void _requireStarted(String id, String depId) {
if (_states[depId] != ModuleState.started) {
throw ModuleRegistryError('$id requires $depId, which is not started');
}
}

/// Rollt einen Start zurueck, dessen Abhaengigkeiten waehrend des Starts
/// gestoppt wurden: das Modul laeuft, darf aber nicht als gestartet gefuehrt
/// werden.
Future<void> _rollbackStart(String id) async {
_states[id] = ModuleState.stopping;
await _activator.stop(_modules[id]!);
_states[id] = ModuleState.stopped;
_activationOrder.remove(id);
}

List<String> _dependenciesOf(String id) {
Expand All @@ -129,7 +241,7 @@ class ModuleRegistry {
if (!_modules.containsKey(id)) {
throw ModuleRegistryError('unknown module id: $id');
}
return _inflight[id] ??= _deactivateWithDependents(id);
return _enqueue(id, _Direction.deactivate);
}

Future<void> _deactivateWithDependents(String id) async {
Expand All @@ -139,29 +251,21 @@ class ModuleRegistry {
if (active == id) break;
if (_states[active] == ModuleState.started &&
_transitivelyDependsOn(active, id)) {
await (_inflight[active] ??= _stopModule(active));
await _stopModule(active);
Comment on lines 252 to +254
}
}
// Direkt stoppen: _inflight[id] enthaelt bereits die Future DIESES
// Deactivate-Laufs (Eintrag in deactivate()); `??=` wuerde sonst auf
// uns selbst warten -> Deadlock. Single-Flight bleibt am EntryPoint
// von deactivate() gewaehrleistet.
// _inflight raeumt ausschliesslich _run: der eigene Eintrag traegt die
// Future, auf die eine bereits eingereihte Gegenrichtung wartet.
if (_states[id] == ModuleState.started) {
await _stopModule(id);
} else {
_inflight.remove(id);
}
}

Future<void> _stopModule(String id) async {
_states[id] = ModuleState.stopping;
try {
await _activator.stop(_modules[id]!);
_states[id] = ModuleState.stopped;
_activationOrder.remove(id);
} finally {
_inflight.remove(id);
}
await _activator.stop(_modules[id]!);
_states[id] = ModuleState.stopped;
_activationOrder.remove(id);
}

bool _transitivelyDependsOn(String m, String target, [Set<String>? seen]) {
Expand All @@ -177,8 +281,13 @@ class ModuleRegistry {

Future<void> deactivateAll() async {
for (final id in List.of(_activationOrder).reversed) {
if (_states[id] == ModuleState.started) {
await (_inflight[id] ??= _stopModule(id));
// Auch Module im Uebergang (stopping/starting) erfassen: haengt dort
// bereits eine Gegenrichtungs-Aktivierung als Nachfolger unter
// _inflight, muss sich das "alles stoppen" dahinter einreihen — sonst
// gewinnt die gequeute Aktivierung und das Modul endet started, obwohl
// deactivateAll der zeitlich letzte Aufruf war (Issue #110).
if (_states[id] == ModuleState.started || _inflight.containsKey(id)) {
await _enqueue(id, _Direction.deactivate);
}
}
}
Expand Down
Loading
Loading