Skip to content

The "dispatcher invariants" pattern referred to elsewhere in the codebase. This doc audits what is actually in source vs. what was planned. Two are true dispatcher invariants; one fleet-side filter lives outside the dispatcher entirely. (A fourth — the #465 destructive-verdict guard — lived in the now-deleted pr-remediator.ts and is no longer in the codebase; see the historical note below.)


What & why

A single chokepoint enforcing invariants is easier to reason about than the same checks scattered across plugins. The agent.skill.request topic is that chokepoint — SkillDispatcherPlugin is the sole subscriber, so anything declared as a "dispatcher invariant" must live in its _dispatch() method.

Audit reality:

#InvariantLocation in sourceGeneralized at dispatcher?
#437Cooldownsrc/executor/skill-dispatcher-plugin.ts:216-230✅ yes
#444Target-registry guardsrc/executor/executor-registry.ts:93-97 (inside resolve())✅ effectively — dispatcher drops on null resolve
#459Synthetic actor filtersrc/plugins/agent-fleet-health-plugin.ts:281-334outside the dispatcher (fleet-health aggregation site)
#465Destructive verdict guardretired — lived in pr-remediator.ts, deleted #776no longer in the codebase

Treat #437 and #444 as the actual dispatcher invariants. #459 is the same architectural pattern (single chokepoint for an invariant) applied at a different chokepoint — the outcome-aggregation site. #465 was that pattern applied at the PR-remediator verdict-handling site; it was removed when pr-remediator.ts was deleted, and the PR-pipeline-violation derivation it guarded was retired along with it.


Dispatcher chokepoint sequence (#437 + #444)

   agent.skill.request


   ┌──────────────────────────────────────────┐
   │ SkillDispatcherPlugin._dispatch()        │
   │                                          │
   │ 1. mark activeExecutions   (line 189)    │  ← gate against concurrent DM turns
   │ 2. skill present?          (line 191)    │  ← drop if missing
   │ 3. resolve(skill, targets) (line 198)    │  ← #444 lives in resolve()
   │      ├─ null  ─►  drop + error response  │
   │      └─ executor                         │
   │ 4. cooldown check          (line 216)    │  ← #437
   │      ├─ trip  ─►  drop + error response  │
   │      └─ pass                             │
   │ 5. flow.item.created                     │
   │ 6. await executor.execute()  (line 286)  │  ← no timeout wrapper
   │ 7. publish outcome           (line 538)  │
   │ 8. finally: drain mailbox    (line 478)  │
   └──────────────────────────────────────────┘

Order matters: registry guard fires before cooldown so a misdirected request gets the more useful diagnostic. Both fire before any side-effects (no flow.item.created, no executor call).


#437 — Cooldown

Why it exists: prevents webhook floods from re-triggering the same (skill, repo) faster than the agent can respond. A second pr_review arriving 5s after the first is almost always a duplicate, not a real new request.

State: in-memory lastDispatchAt: Map<string, number> (skill-dispatcher-plugin.ts:124). Key: {skill}:{owner}/{repo} or {skill}:_ if no repo context.

Defaults: bug_triage 30s, pr_review 30s, security_triage 60s, others unset (no cooldown). Env override: WORKSTACEAN_COOLDOWN_MS_<SKILL> (line 54–73).

On trip:

  • console.warn with elapsed/window/remaining (line 223–226)
  • Error response published to reply.topic (line 227)
  • No bus event for the trip itself — dashboard can't count cooldown drops

Greenfield rule: absence of a default = no cooldown. There is no enabled: false flag; the absence is the signal.


#444 — Target-registry guard

Why it exists: if payload.targets: ["quinn"] points at an agent that isn't enrolled (typo, undeployed, hostname changed — see #608), failing loudly at dispatch beats a silent timeout downstream.

Implementation: inside ExecutorRegistry.resolve(skill, targets) (executor-registry.ts:93–97):

if targets.length > 0:
    for each registration:
        if registration.agentName ∈ targets:
            return registration.executor
    return null  ← dispatcher drops on this null
else:
    fall through to skill-based routing

State: _registrations: Array<ExecutorRegistration> (executor-registry.ts:45). Populated at startup by AgentRuntimePlugin (in-process agents) and SkillBrokerPlugin (A2A discovery, async).

On trip:

  • Dispatcher logs [skill-dispatcher] No executor found for targets [...] or skill "..." — dropping (line 205)
  • Error response to reply.topic
  • No HITL escalation

Note on the "all" sentinel: memory referenced an "all" broadcast opt-out. Not present in current source. Either it was removed or was never implemented. If broadcast is needed, the absence of targets[] already triggers skill-based routing — which dispatches to the first matching executor, not all of them.


#459 — Synthetic actor filter (lives elsewhere)

Where: AgentFleetHealthPlugin._record (agent-fleet-health-plugin.ts:281–334). Subscribes to autonomous.outcome.#, not agent.skill.request.

Why it's not at the dispatcher: the dispatcher publishes outcomes; it cannot also gate them or it would refuse to publish telemetry about itself. The filter belongs at the consumer — fleet-health is the consumer that cares about whether systemActor is an actual agent vs. a plugin label.

What it does:

on autonomous.outcome.{actor}.{skill}:
    if ExecutorRegistry.list().some(r => r.agentName === actor):
        → agentWindows[actor].push(outcome)
        → contributes to agentCount, maxFailureRate1h, orphanedSkillCount
    else:
        → systemActorWindows[actor].push(outcome)
        → exposed separately in FleetHealthSnapshot.systemActors[]
        → logged once-per-distinct-actor at warn level

Known synthetic actors: user.

On trip: one-time console.warn per actor, no escalation. The point is bucketing, not blocking.


#465 — Destructive verdict guard (RETIRED — no longer in code)

Status: removed. This invariant lived in lib/plugins/pr-remediator.ts, which was deleted in #776. It is documented here only as history — there is no destructive-verdict guard in the current codebase, because the LLM-driven PR-close verdict it guarded no longer exists in workstacean.

What it did: inside the old pr-remediator's diagnose_pr_stuck verdict handling, when an LLM verdict on a stuck PR was decomposable (i.e. "close this and re-cut as smaller PRs"), the handler checked whether the PR was a promotion PR (head ∈ {dev, staging} OR base ∈ {main, staging} OR title starts with "Promote"). If so, the close was suppressed and an HITL escalation emitted instead.

Why it's gone: workstacean no longer re-derives PR-pipeline violations or issues destructive PR verdicts. That whole responsibility was retired along with the PR-remediator, so the promotion-PR destructive-close path that #465 guarded no longer exists on the workstacean side.

If a new LLM-driven destructive verb appears later (e.g. delete issue, force-update ref), the right pattern is the same one #465 used — guard it next to the verb's consumer, and only promote it to a dispatcher invariant if it generalizes across skills.


Telemetry (resolved by #620 + #622)

The dispatcher publishes dispatch.dropped.{reason} at each chokepoint drop site (shipped in #620), and the dispatch-drop-escalator plugin watches for storms — N drops on the same key in M minutes → operator DM via operator.message.request (shipped in #622).

TopicPublished atSubscribed by
dispatch.dropped.no_skillskill-dispatcher-plugin.ts:201-208dashboard, dispatch-drop-escalator
dispatch.dropped.target_unresolved:212-223dashboard, dispatch-drop-escalator
dispatch.dropped.cooldown:236-249dashboard, dispatch-drop-escalator

Payload shape: DispatchDroppedPayload in src/event-bus/payloads.ts — uniform across reasons, with reason-specific optional fields (cooldownKey, cooldownWindowMs, cooldownRemainingMs). The console.warn at each site is kept for log-tail visibility — bus and stdout fire independently.

The synthetic-actor filter (#459) still doesn't publish a "filtered" event — by design, since synthetic actors are expected and the bucketing is the signal. If we ever need to detect a new synthetic actor appearing, that's worth surfacing — open follow-up.


When to add a new invariant

If you find yourself adding a check that:

  • Must fire before any executor work happens, and
  • Applies to all (skill, target) pairs (not just one skill / one plugin)

…then add it to _dispatch() in the same sequence (after registry resolve, before cooldown is fine — the order between these is semantic, see #437 above).

If the check is specific to one skill or one verb (as #465's now-retired PR-close guard was), put it next to the consumer, not at the dispatcher. The dispatcher is for invariants every skill must respect.


protoWorkstacean — a switchboard, not an agent.