Skip to content

SP0.9: sim-thread applier draining ActuatorCommandQueue via actuator ports#737

Merged
bedaHovorka merged 3 commits into
goal-10from
copilot/sp09-drain-actuator-command-queue
Jul 9, 2026
Merged

SP0.9: sim-thread applier draining ActuatorCommandQueue via actuator ports#737
bedaHovorka merged 3 commits into
goal-10from
copilot/sp09-drain-actuator-command-queue

Conversation

Copilot AI commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Drains ActuatorCommandQueue (SP0.8) on the kDisco sim thread and applies each DispatchDecision through the SP0.6 actuator ports. All simulation mutation stays on the sim thread.

Mechanism choice: option 2 (generic ControlStepListener in :core)

Option 1 (standalone kDisco Process in :dispatcher-agent) is infeasible: Process.activate() must be called from the sim thread, but :core cannot import :dispatcher-agent types (circular dependency). A ControlStepListener interface in :core avoids the coupling — :dispatcher-agent implements it; :core only knows the interface.

:core changes

  • ControlStepListener — new fun interface with single onControlStep(), placed in core/sim/. No dispatcher semantics, purely a sim-thread callback hook.
  • ShuntingLoop — three additions:
    • var controlStepListener: ControlStepListener? — nullable field set by the wiring layer before context.run(); null by default so all existing callers are unaffected
    • fun approveQueuedTrain(trainId) — public delegate to the existing private applyApproveTrain, callable from the applier's callback
    • controlStepListener?.onControlStep() at the top of iteration(), before buildAdmissionObservation(), so drained decisions are visible to the inline dispatcher

:dispatcher-agent changes

  • DispatchDecisionApplier — implements ControlStepListener; drains the queue and routes each decision:
class DispatchDecisionApplier(
    private val queue: ActuatorCommandQueue,
    private val networkPort: NetworkActuatorPort,
    private val onApproveTrain: (String) -> Unit,
) : ControlStepListener {
    override fun onControlStep() {
        queue.drain().forEach { applyDecision(it) }
    }
    private fun applyDecision(decision: DispatchDecision) = when (decision) {
        is DispatchDecision.ApproveTrain -> onApproveTrain(decision.trainId)
        is DispatchDecision.ReservePath  -> networkPort.requestRoute(decision.from, decision.to)
        is DispatchDecision.NoAction     -> Unit
    }
}
  • DispatchDecisionApplierTest — 14 tests: decision routing per subtype, FIFO drain order, empty-queue no-ops, all RouteRequestResult outcomes, and four thread-identity assertions verifying that actuator calls and the approve callback execute on the onControlStep caller thread (never the posting/driver thread).

…uator ports

- Add ControlStepListener fun interface to :core/sim — generic hook called on the
  kDisco sim thread at the start of each ShuntingLoop iteration; no dispatcher
  logic in :core (satisfies option-2 gate criterion).

- Modify ShuntingLoop:
  - `var controlStepListener: ControlStepListener?` — set by wiring layer before
    context.run(); null by default so all existing callers are unaffected.
  - `fun approveQueuedTrain(trainId)` — public delegate to applyApproveTrain,
    used by DispatchDecisionApplier's onApproveTrain callback.
  - `controlStepListener?.onControlStep()` called at top of iteration() to drain
    and apply any pending queue decisions before the inline dispatcher runs.

- Add DispatchDecisionApplier to :dispatcher-agent — implements ControlStepListener;
  drains ActuatorCommandQueue and routes each DispatchDecision:
  ApproveTrain → onApproveTrain callback,
  ReservePath  → NetworkActuatorPort.requestRoute,
  NoAction     → no-op.

- Add DispatchDecisionApplierTest (14 tests) covering decision routing, FIFO
  order, empty-queue no-ops, all RouteRequestResult outcomes, and four
  thread-identity assertions asserting actuator calls and the approve callback
  always execute on the onControlStep thread (not the posting/driver thread).

- Add io.mockk:mockk to :dispatcher-agent test dependencies.

Closes #731.
Copilot AI changed the title [WIP] Implement mechanism for draining ActuatorCommandQueue SP0.9: sim-thread applier draining ActuatorCommandQueue via actuator ports Jul 9, 2026
Copilot AI requested a review from bedaHovorka July 9, 2026 05:26
Addresses the two Important issues from the PR #737 code review:

* Add ShuntingLoopControlStepListenerTest (:core jvmTest, integration-tagged):
  - proves controlStepListener.onControlStep() is invoked exactly once per
    iteration, before the admission Dispatcher.decide() call (the load-bearing
    ordering invariant the SP0.9 design rests on) — endTime=0L gives one
    iteration; a custom Dispatcher records that the listener already ran.
  - proves approveQueuedTrain delegates to applyApproveTrain and moves a queued
    train into the approved set — a NoAction-only dispatcher makes the callback
    the only approval path; the listener consumes the captured id once
    (getAndSet(null)) so re-approval cannot throw in applyApproveTrain's
    requireNotNull and crash the kDisco process mid-iteration.
  Closes the conservative-sim test-coverage gap (the iteration() seam had no
  direct test; DispatchDecisionApplierTest only exercised onControlStep on the
  test thread).

* Make the two `when` constructs in DispatchDecisionApplier compile-enforced
  exhaustive over the sealed DispatchDecision / RouteRequestResult types
  (expression-body `when`), matching DefaultNetworkActuatorPort.requestRoute.
  A future subtype (e.g. HoldTrain in SP2b, #556) is now a compile error
  instead of a silently-dropped decision.

Also records the Minor #4 coupling observation (ApproveTrain-via-callback) as
a code comment in DispatchDecisionApplier's onApproveTrain KDoc and as a
comment on GitHub issue #556, recommending a TrainAdmissionPort extraction for
SP2b train-lifecycle commands.

Verified: :core:integrationTest 2/2, :dispatcher-agent:test + :core:jvmTest
2177/2177, detekt + ktlintCheck clean.

Co-Authored-By: Claude <noreply@anthropic.com>
@bedaHovorka bedaHovorka marked this pull request as ready for review July 9, 2026 18:12
@bedaHovorka bedaHovorka merged commit 23bc585 into goal-10 Jul 9, 2026
3 checks passed
@bedaHovorka bedaHovorka deleted the copilot/sp09-drain-actuator-command-queue branch July 9, 2026 18:26
@sonarqubecloud

sonarqubecloud Bot commented Jul 9, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
25.0% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SP0.9: Sim-thread applier draining ActuatorCommandQueue via actuator ports

2 participants