Symptom

A partially-implemented KrillApp.Trigger.Timer node never started its countdown. Dropping a Timer under a source and firing the source did nothing — no countdown, no observers fired — and the node had no editor (the editor when silently fell through to the empty else).

Root cause

Two wiring gaps in the half-finished feature:

  1. KrillApp.Trigger.Timer was missing from KrillAppEmit.processor(), so it hit the else -> null arm. ServerNodeManager.invoke() dispatches via target.type.processor()?.onInvoke(...); a null processor means the server-side onInvoke was never reached, so TimerBoss never ran.
  2. ServerTimerProcessor.onInvoke routed both EXECUTE and RESET to timerBoss.stop() — so even if dispatch had worked, EXECUTE would have stopped (a no-op) instead of starting the countdown. KrillAppMeta.meta() also handed the node a generic TriggerMetaData (no delay field) instead of TimerMetaData, and TimerProcessor was absent from ClientProcessModule’s binds.

Fix

Added the KrillApp.Trigger.Timer -> TimerProcessor arm to KrillAppEmit.processor(), the TimerProcessor::class binding to ClientProcessModule, and pointed KrillAppMeta.meta() at TimerMetaData. Corrected ServerTimerProcessor.onInvoke to EXECUTE -> start, RESET -> stop. Rebuilt TimerBoss around a single cancellable delay(meta.delay) (no wall-clock busy loop), persisting PROCESSING on start, firing executeSources + clearing state on completion, and force-broadcasting STATE_CHANGE(RESET) on stop so clients drop the countdown pie. The client draws the pie purely from timerProgress(now, startTimestamp, delay), and the generic 1500ms activity-pulse reset is skipped for Timer nodes so a long countdown isn’t truncated.

Follow-up: stale-meta ClassCastException

Once dispatch worked, a Timer node persisted before the meta() fix still carried a generic TriggerMetaData. TimerBoss.start’s hard cast (node.meta as TimerMetaData) then threw an uncaught ClassCastException on the IO scope — and the editor’s as? TimerMetaData ?: return silently hid the form. Fixed with an asTimerMetaData() coercion (carries over SourceMetaData wiring, supplies the default delay) applied at the server choke point (TimerBoss.start, which persists the repair), in EditTimer (so the form renders and the next edit writes back the corrected meta), and in the IconManager countdown overlay (so the pie still animates when a client’s in-memory copy lags behind a state-only SSE update). Lesson: a node’s persisted meta subtype can lag a meta() change — never hard-cast it; coerce/heal.

Follow-up: Sources tab read-only (missing withWiring arm)

The Sources tab toggles (sources / Invoked-By checkboxes / Execute-Reset radio) were inert on a Timer. SourcesTab writes through NodeMetaData.withWiring(), a per-subtype when whose else -> this returns the meta unchanged for any unlisted type — so every edit was a silent no-op. TimerMetaData was missing from both withWiring and its lockstep twin withParentSourceDefault. Added the arm to both. Also hardened EditTimer: it now writes only the delay delta read from the freshest node (instead of a whole-meta draft), so a concurrent Sources tab edit is never clobbered, and it stages via edited() (Save → submit()) like every other editor.

Prevention

The “add a node type” checklist must include the dispatch entry point (KrillAppEmit.processor()), not just the handler body — a missing arm there is a silent no-op, not a crash. TimerWiringTest now guards that KrillApp.Trigger.Timer.meta() returns TimerMetaData, and ServerTimerProcessorTest pins EXECUTE -> start / RESET -> stop so the verb-swap regression can’t return. When a refactor changes how a unit is invoked, verify the dispatch path resolves to a processor, not only that the processor’s logic is correct.