Skip to content

Promote dev → main: A2A routing + chokepoint patterns #4 & #5 + hardening (v0.7.23 candidate)#475

Merged
mabry1985 merged 44 commits intomainfrom
dev
Apr 22, 2026
Merged

Promote dev → main: A2A routing + chokepoint patterns #4 & #5 + hardening (v0.7.23 candidate)#475
mabry1985 merged 44 commits intomainfrom
dev

Conversation

@mabry1985
Copy link
Copy Markdown

@mabry1985 mabry1985 commented Apr 22, 2026

Promotion of 41 commits accumulated on dev since v0.7.22 (#466).

Highlights

A2A correctness (#471 + earlier)

GOAP chokepoint-invariant pattern — 5 instances now landed

This promotion completes the pattern-cluster around enforcing invariants at single chokepoints:

Hardening cleanup (#467 follow-ups from CodeRabbit on #466)

Ceremony / GOAP loop hygiene

Fleet

Other

What's NOT in this promotion

Open issues this closes

`closes #426 #427 #428 #430 #436 #437 #444 #453 #459 #465 #467`

(#471 already closed by #473's merge to dev; will re-close on main once promoted.)

Test plan

Notes for the human gate

  • Topology note: the back-merge in chore: back-merge main → dev #468 was force-merged after CodeRabbit timed out, so dev's tip is a squash-equivalent of `14e2045` rather than carrying it as an ancestor commit. Content is identical. Future merge-base may compute slightly oddly but the diff is correct.
  • Version bump (`0.7.22 → 0.7.23`) not yet in this PR — happy to add as a separate commit on dev or fold into the promote workflow per your preference.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added environment variable support for external URL configuration and internal host settings.
    • Enhanced PR handling with automatic detection of promotion-style pull requests.
    • Implemented human-in-the-loop (HITL) policy support with configurable timeout behavior.
    • Improved downstream agent error responses with HTML error detection and sanitization.
  • Documentation

    • Updated configuration documentation with new environment variables.
    • Revised agent rollcall instructions.
  • Tests

    • Added comprehensive test coverage for new PR remediation and agent communication features.

github-actions Bot and others added 30 commits April 16, 2026 22:31
Acknowledges main's squash commit as an ancestor to prevent phantom conflicts on the next dev→main promotion.
All 27 references to https://protolabs.ai/a2a/ext/* changed to
https://proto-labs.ai/a2a/ext/* to match the actual domain. These
URIs are opaque identifiers (not published specs today) but should
reference a domain we own.

Breaking: external agents (Quinn, protoPen) whose cards declare the
old URI will stop matching the registry until they update. Filed on
Quinn to update her card.

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Both services decommissioned. Containers stopped + removed.
Only reference in protoWorkstacean was the rollcall script.

Note: homelab-iac/stacks/ai/docker-compose.yml still has a
worldmonitor network reference at line 521 + service at line 833.
Needs separate cleanup in that repo.

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ls (#411)

Two changes:

1. Replace the basic `web_search` tool (5 results, hardcoded engines)
   with `searxng_search` — adapted from rabbit-hole.io's full-surface
   SearXNG integration. New capabilities:
   - Category routing: general, news, science, it
   - Time range filtering: day, week, month, year
   - Bang syntax: !wp (Wikipedia), !scholar, !gh (GitHub)
   - Infoboxes, direct answers, suggestions in response
   - Configurable max_results (default 10, was 5)

   Updated in both bus-tools.ts (@protolabsai/sdk pattern) and
   deep-agent-executor.ts (LangChain pattern).

2. Give Ava three fleet health tools she was missing:
   - get_ci_health — CI success rates across repos
   - get_pr_pipeline — open PRs, conflicts, staleness
   - get_incidents — security/ops incidents

   Ava can now answer fleet health questions directly instead of
   always delegating to Quinn.

Ava's tool count: 10 → 13. Tool rename: web_search → searxng_search
(greenfield, no backward compat alias).

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
protoAgent is the new GitHub Template repo that replaces per-agent
A2A bootstrapping. Registers it as an active dev project owned by
Quinn, matching the shape of existing entries. Plane / GitHub
webhook / Discord provisioning remain TODO — those integrations
aren't configured in this deployment, so the onboard plugin
skipped them.

Co-authored-by: Josh <artificialcitizens@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Acknowledges main's squash commit as an ancestor to prevent phantom conflicts on the next dev→main promotion.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…e bug (#415)

Ava agent audit + overhaul:
- Tools: 10 → 22 (direct observation, propose_config_change, incident reporting)
- Skills: 3 → 7 (debug_ci_failures, fleet_incident_response, downshift_models, investigate_orphaned_skills)
- System prompt rewritten: self-improvement instructions, escalation policy, GOAP-dispatch playbook
- DeepAgentExecutor now applies skill-level systemPromptOverride (goal_proposal, diagnose_pr_stuck)
- Fix ceremony loader bug: disabled ceremonies were filtered out, preventing hot-reload from cancelling timers
- Clean up board.pr-audit.yaml (remove spurious action field, restore schedule, keep disabled)
- Update docs: README, deep-agent runtime, agent-skills reference, self-improving loop

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…-approve (#417)

Two root causes prevented PRs from being auto-merged:

1. Dispatch gap: tier_0 short-circuit in ActionDispatcherPlugin completed
   all actions immediately without dispatching to agent.skill.request.
   Every action in actions.yaml is tier_0, so the fireAndForget path
   (which publishes the skill request) was unreachable dead code.
   Fix: tier_0 now falls through when meta.fireAndForget is set.

2. Approval gap: readyToMerge requires reviewState=approved, but
   auto-approve only covered dependabot/renovate/promote:/chore(deps.
   Human PRs, release PRs (chore(release), and github-actions PRs
   all lacked approved reviews and sat indefinitely.
   Fix: added app/github-actions to authors, chore(release, chore:,
   docs( to safe title prefixes.

Additionally, PrRemediatorPlugin now self-dispatches remediation on
every world.state.updated tick — checking for readyToMerge, dirty,
failingCi, and changesRequested PRs directly from cached domain data.
This removes the dependency on GOAP dispatch reaching the plugin via
pr.remediate.* topics (which were never published in production after
Arc 1.4 removed meta.topic routing).

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ss fleet (#419)

Adds a github_issues domain that polls /repos/{repo}/issues?state=open
for all managed projects and classifies by label (critical, bug,
enhancement). Three GOAP goals enforce issue hygiene:

  - issues.zero_critical (critical severity, max: 0)
  - issues.zero_bugs (high severity, max: 0)
  - issues.total_low (medium severity, max: 5)

Each goal has a matching alert action and a triage dispatch action
that invokes Ava's new issue_triage skill. The skill instructs Ava
to resolve, convert to board features, delegate, or close issues
with rationale — driving toward zero open issues across all repos.

Domain polls every 5 minutes (issue velocity is low, GitHub rate
limits are a concern with 6+ repos).

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
)

Two enhancements to reach issue zero:

manage_board list (#247):
  - Added GET /api/board/features/list endpoint proxying to Studio
  - Added "list" action to manage_board tool with status filter
  - Ava can now query "show me all blocked features" directly

a2a.trace extension (#359):
  - New langfuse-trace extension stamps a2a.trace metadata on all
    outbound A2A dispatches (traceId, callerAgent, skill, project)
  - Quinn reads this to link Langfuse traces across agent boundaries
  - Registered at startup alongside cost/confidence/blast extensions

Closes #247, closes #359.

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: manage_board list action (#247) + a2a.trace extension (#359)

Two enhancements to reach issue zero:

manage_board list (#247):
  - Added GET /api/board/features/list endpoint proxying to Studio
  - Added "list" action to manage_board tool with status filter
  - Ava can now query "show me all blocked features" directly

a2a.trace extension (#359):
  - New langfuse-trace extension stamps a2a.trace metadata on all
    outbound A2A dispatches (traceId, callerAgent, skill, project)
  - Quinn reads this to link Langfuse traces across agent boundaries
  - Registered at startup alongside cost/confidence/blast extensions

Closes #247, closes #359.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(pr-remediator): case-insensitive auto-approve prefix matching

"Promote dev to main" titles start with capital P, but the prefix
check was case-sensitive against "promote:". Now lowercases the
title before matching so both "promote:" and "Promote" patterns
are caught.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…) (#427)

Closes the structural gap where 6+ tier_0 fire-and-forget alert skills had
no registered executor, causing SkillDispatcherPlugin to log "No executor
found" and silently drop the dispatch on every GOAP planning cycle.

- AlertSkillExecutorPlugin registers FunctionExecutors for all 24 bare
  alert.* actions in workspace/actions.yaml. Each translates the dispatch
  into a structured message.outbound.discord.alert event consumed by the
  existing WorldEngineAlertPlugin webhook routing.
- validate-action-executors.ts cross-checks the loaded ActionRegistry
  against the live ExecutorRegistry at startup. Surfaces every gap as a
  HIGH-severity Discord alert (goal platform.skills_unwired) and a loud
  console.error. Set WORKSTACEAN_STRICT_WIRING=1 to crash startup instead.
- action.issues_triage_bugs already routes correctly via meta.agentId=ava
  to the existing DeepAgentExecutor for Ava's issue_triage skill — no
  duplicate wiring needed (greenfield).

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CeremonyStateExtension was publishing { domain, data } envelopes on
`world.state.snapshot` after every ceremony completion. GoalEvaluatorPlugin
subscribes to `world.state.#`, treated the malformed payload as a WorldState,
and emitted a "Selector ... not found" violation for every loaded goal on
every ceremony tick (the cluster of 25+ violations at each :15/:30 boundary
in the live container logs). All listed selectors (flow.efficiency.ratio,
services.discord.connected, agent_health.agentCount, etc.) actually exist
in the producer output — the goals are correct.

Changes:
- Move ceremony snapshot publish to `ceremony.state.snapshot` (off the
  world.state.# namespace). Leaves the existing CeremoniesState shape and
  consumers unchanged.
- Goal evaluator: defensive payload shape check. Reject single-domain
  envelopes ({ domain, data }) and other non-WorldState payloads loud-once
  instead of generating one violation per goal.
- Goal evaluator: startup selector validator. After the first valid world
  state arrives, walk every loaded goal's selector and HIGH-log any that
  doesn't resolve. Re-armed on goals.reload / config.reload so drift caught
  by future hot-reloads also surfaces.
- Tests: regression guard that CeremonyStateExtension does not publish on
  world.state.#; goal evaluator ignores malformed payloads; validator
  catches an intentionally broken selector.

Closes #424

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…429)

The Claude Code skill was calling the homelab-iac copy of agent-rollcall.sh,
which had drifted from this repo's copy. The in-repo script knows about
the in-process DeepAgent runtime (Ava, protoBot, Tuner) and the current
A2A fleet; the homelab-iac copy still probed for the archived ava-agent
container and the deprecated protoaudio/protovoice services.

Single source of truth: this repo. The homelab-iac copy was separately
synced in homelab-iac@64e8dcf.

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: extension URIs use proto-labs.ai (not protolabs.ai) (#407)

All 27 references to https://protolabs.ai/a2a/ext/* changed to
https://proto-labs.ai/a2a/ext/* to match the actual domain. These
URIs are opaque identifiers (not published specs today) but should
reference a domain we own.

Breaking: external agents (Quinn, protoPen) whose cards declare the
old URI will stop matching the registry until they update. Filed on
Quinn to update her card.




* chore(release): bump to v0.7.20 (#408)



* chore: remove protoaudio + protovoice from agent rollcall (#410)

Both services decommissioned. Containers stopped + removed.
Only reference in protoWorkstacean was the rollcall script.

Note: homelab-iac/stacks/ai/docker-compose.yml still has a
worldmonitor network reference at line 521 + service at line 833.
Needs separate cleanup in that repo.




* feat: upgrade web_search → searxng_search + give Ava fleet health tools (#411)

Two changes:

1. Replace the basic `web_search` tool (5 results, hardcoded engines)
   with `searxng_search` — adapted from rabbit-hole.io's full-surface
   SearXNG integration. New capabilities:
   - Category routing: general, news, science, it
   - Time range filtering: day, week, month, year
   - Bang syntax: !wp (Wikipedia), !scholar, !gh (GitHub)
   - Infoboxes, direct answers, suggestions in response
   - Configurable max_results (default 10, was 5)

   Updated in both bus-tools.ts (@protolabsai/sdk pattern) and
   deep-agent-executor.ts (LangChain pattern).

2. Give Ava three fleet health tools she was missing:
   - get_ci_health — CI success rates across repos
   - get_pr_pipeline — open PRs, conflicts, staleness
   - get_incidents — security/ops incidents

   Ava can now answer fleet health questions directly instead of
   always delegating to Quinn.

Ava's tool count: 10 → 13. Tool rename: web_search → searxng_search
(greenfield, no backward compat alias).




* chore(projects): register protoAgent in projects.yaml (#414)

protoAgent is the new GitHub Template repo that replaces per-agent
A2A bootstrapping. Registers it as an active dev project owned by
Quinn, matching the shape of existing entries. Plane / GitHub
webhook / Discord provisioning remain TODO — those integrations
aren't configured in this deployment, so the onboard plugin
skipped them.




* chore(release): bump to v0.7.21 (#413)



* feat(ava): expand helm toolset, wire GOAP skills, fix ceremony disable bug (#415)

Ava agent audit + overhaul:
- Tools: 10 → 22 (direct observation, propose_config_change, incident reporting)
- Skills: 3 → 7 (debug_ci_failures, fleet_incident_response, downshift_models, investigate_orphaned_skills)
- System prompt rewritten: self-improvement instructions, escalation policy, GOAP-dispatch playbook
- DeepAgentExecutor now applies skill-level systemPromptOverride (goal_proposal, diagnose_pr_stuck)
- Fix ceremony loader bug: disabled ceremonies were filtered out, preventing hot-reload from cancelling timers
- Clean up board.pr-audit.yaml (remove spurious action field, restore schedule, keep disabled)
- Update docs: README, deep-agent runtime, agent-skills reference, self-improving loop




* fix(pr-remediator): close dispatch gap — self-dispatch + broaden auto-approve (#417)

Two root causes prevented PRs from being auto-merged:

1. Dispatch gap: tier_0 short-circuit in ActionDispatcherPlugin completed
   all actions immediately without dispatching to agent.skill.request.
   Every action in actions.yaml is tier_0, so the fireAndForget path
   (which publishes the skill request) was unreachable dead code.
   Fix: tier_0 now falls through when meta.fireAndForget is set.

2. Approval gap: readyToMerge requires reviewState=approved, but
   auto-approve only covered dependabot/renovate/promote:/chore(deps.
   Human PRs, release PRs (chore(release), and github-actions PRs
   all lacked approved reviews and sat indefinitely.
   Fix: added app/github-actions to authors, chore(release, chore:,
   docs( to safe title prefixes.

Additionally, PrRemediatorPlugin now self-dispatches remediation on
every world.state.updated tick — checking for readyToMerge, dirty,
failingCi, and changesRequested PRs directly from cached domain data.
This removes the dependency on GOAP dispatch reaching the plugin via
pr.remediate.* topics (which were never published in production after
Arc 1.4 removed meta.topic routing).




* feat(goap): issue_zero domain + goals — track open GitHub issues across fleet (#419)

Adds a github_issues domain that polls /repos/{repo}/issues?state=open
for all managed projects and classifies by label (critical, bug,
enhancement). Three GOAP goals enforce issue hygiene:

  - issues.zero_critical (critical severity, max: 0)
  - issues.zero_bugs (high severity, max: 0)
  - issues.total_low (medium severity, max: 5)

Each goal has a matching alert action and a triage dispatch action
that invokes Ava's new issue_triage skill. The skill instructs Ava
to resolve, convert to board features, delegate, or close issues
with rationale — driving toward zero open issues across all repos.

Domain polls every 5 minutes (issue velocity is low, GitHub rate
limits are a concern with 6+ repos).




* feat: manage_board list action (#247) + a2a.trace extension (#359) (#420)

Two enhancements to reach issue zero:

manage_board list (#247):
  - Added GET /api/board/features/list endpoint proxying to Studio
  - Added "list" action to manage_board tool with status filter
  - Ava can now query "show me all blocked features" directly

a2a.trace extension (#359):
  - New langfuse-trace extension stamps a2a.trace metadata on all
    outbound A2A dispatches (traceId, callerAgent, skill, project)
  - Quinn reads this to link Langfuse traces across agent boundaries
  - Registered at startup alongside cost/confidence/blast extensions

Closes #247, closes #359.




* fix(pr-remediator): case-insensitive auto-approve prefix matching (#421)

* feat: manage_board list action (#247) + a2a.trace extension (#359)

Two enhancements to reach issue zero:

manage_board list (#247):
  - Added GET /api/board/features/list endpoint proxying to Studio
  - Added "list" action to manage_board tool with status filter
  - Ava can now query "show me all blocked features" directly

a2a.trace extension (#359):
  - New langfuse-trace extension stamps a2a.trace metadata on all
    outbound A2A dispatches (traceId, callerAgent, skill, project)
  - Quinn reads this to link Langfuse traces across agent boundaries
  - Registered at startup alongside cost/confidence/blast extensions

Closes #247, closes #359.



* fix(pr-remediator): case-insensitive auto-approve prefix matching

"Promote dev to main" titles start with capital P, but the prefix
check was case-sensitive against "promote:". Now lowercases the
title before matching so both "promote:" and "Promote" patterns
are caught.



---------




---------

Co-authored-by: Josh Mabry <31560031+mabry1985@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Josh <artificialcitizens@gmail.com>
…th_discord (#431)

Adds CeremonySkillExecutorPlugin — registers FunctionExecutors that bridge
GOAP `ceremony.*` actions to the matching `ceremony.<id>.execute` topic
CeremonyPlugin already listens for. Without this bridge,
SkillDispatcherPlugin dropped every dispatch with "No executor found …"
and (post-#427) emitted HIGH platform.skills_unwired alerts every cycle.

Mirrors the alert-skill-executor-plugin pattern from #427 — explicit
action→ceremony id mapping, install order matters (after registry,
before skill-dispatcher).

Partial fix for #430.

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…432)

Closes the structural gap where 5 actions in workspace/actions.yaml route
to handlers in PrRemediatorPlugin but had no registered executor:
  - action.pr_update_branch    → pr.remediate.update_branch
  - action.pr_merge_ready      → pr.remediate.merge_ready
  - action.pr_fix_ci           → pr.remediate.fix_ci
  - action.pr_address_feedback → pr.remediate.address_feedback
  - action.dispatch_backmerge  → pr.backmerge.dispatch

Before this change SkillDispatcherPlugin logged "No executor found" and
dropped the dispatch every GOAP cycle. After PR #427's startup validator
the same gap raised platform.skills_unwired HIGH every tick.

Wiring follows the AlertSkillExecutorPlugin pattern from #427:
  - PrRemediatorSkillExecutorPlugin registers FunctionExecutors that
    publish on the existing pr-remediator subscription topics, keeping
    "bus is the contract" — no plugin holds a reference to the other.
  - Executors are fire-and-forget per actions.yaml meta. They return a
    successful SkillResult immediately; pr-remediator's handler runs
    asynchronously on the bus subscription.
  - Install order matches alert-skill-executor: AFTER ExecutorRegistry
    construction, BEFORE skill-dispatcher.

For action.pr_merge_ready specifically, the meta.hitlPolicy
(ttlMs: 1800000, onTimeout: approve) is now honoured. The executor
forwards meta into the trigger payload; _handleMergeReady extracts it
via _extractHitlPolicy and passes it to _emitHitlApproval, which
populates HITLRequest.{ttlMs, onTimeout}. HITLPlugin already auto-
publishes a synthetic approve response when onTimeout=approve fires.

Closes part of #430. Ceremony + protoMaker actions ship in a separate PR.

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
protoMaker (apps/server in protoLabsAI/ava) has been A2A-ready for a while —
serves agent-card.json with 10 skills including the two referenced by
unwired GOAP actions:
  - action.protomaker_triage_blocked → skill board_health
  - action.protomaker_start_auto_mode → skill auto_mode

Both actions targeted [protomaker], but no agent named "protomaker" was
registered, so the dispatcher couldn't route. Adding the entry closes
the routing gap; A2AExecutor's existing target-matching does the rest.

Endpoint: http://automaker-server:3008/a2a (verified from inside the
workstacean container with AVA_API_KEY → JSON-RPC 2.0 response).

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#433 added `subscribesTo: message.inbound.github.#` to the new protomaker
agent registration, copy-pasted from quinn's pattern. That was wrong:
protoMaker is reached via explicit GOAP `targets: [protomaker]` dispatches
(action.protomaker_triage_blocked, action.protomaker_start_auto_mode), not
as a broadcast inbound listener.

Quinn already subscribes to all GitHub inbound and dispatches `bug_triage`
on protoMaker's behalf. Having protomaker subscribe to the same broadcast
topic is one of the contributing paths to the duplicate-triage spam loop
filed as protoLabsAI/protoMaker#3503 (the root cause is Quinn's handler
not being idempotent — but this cleanup removes one extra firing path).

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…me (#435)

PR #411 renamed the tool from web_search to searxng_search in
src/agent-runtime/tools/bus-tools.ts (line 393), but ava.yaml still
declared the old name. Result: at startup the runtime warns
"agent ava declares unknown tools: web_search" and Ava ends up with no
search capability — when asked, she explicitly responds "I don't have a
searxng_search or web_search tool in my current toolkit."

This is the config side of the half-finished rename that PR #411 missed.
After this lands and workstacean restarts, Ava's toolkit should include
searxng_search and the unknown-tools warning should be empty for her.

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…up (#436)

Two GOAP actions on goal fleet.no_agent_stuck were spamming Discord
because they had no `effects` and no cooldown:

  - alert.fleet_agent_stuck → posts a Discord alert
  - action.fleet_incident_response → dispatches Ava to file an incident,
    page on-call, and pause routing

Observed 2026-04-20: when auto-triage-sweep hit 100% failure rate on
bug_triage (cascading from the non-idempotent handler in
protoLabsAI/protoMaker#3503), GOAP re-fired both actions every planning
cycle. Ava filed INC-003 through INC-009 in ~30 seconds, each posting to
Discord. The pause routing succeeded but the rolling 1h failure rate
metric doesn't drop instantly, so the goal stayed violated and the loop
kept re-firing.

Disabling both actions until proper dedup lands. Reinstate when:
  1. action.fleet_incident_response gains a cooldown OR an `effects`
     marker that satisfies the goal for the cooldown window
  2. Ava's fleet_incident_response skill checks for an existing open
     incident on the same agent before filing a new one
  3. alert.fleet_agent_stuck gains per-agent rate limiting

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (#451)

After #436 disabled action.fleet_incident_response, observed similar
loop spam from FOUR more action.* dispatches that share the same
architectural bug (no cooldown, no satisfying effects → re-fire every
GOAP cycle while goal stays violated):

  action.fleet_investigate_orphaned_skills  — Ava posts orphaned-skill
                                               diagnosis to Discord ops
                                               on every cycle (8+ posts
                                               in 1 min observed)
  action.issues_triage_critical             — ~447 fires in 30 min
  action.issues_triage_bugs                 — ~447 fires in 30 min;
                                               compounds with #3503
                                               by re-triaging same issues
  action.fleet_downshift_models             — same pattern when cost
                                               exceeds budget

ALL share the same pattern as the alert.* actions and the original
fleet_incident_response: effects: [] + no cooldownMs + persistent goal
violation = infinite re-fire.

Mitigation only — Ava temporarily can't auto-investigate orphaned
skills or auto-triage new GitHub issues. Reinstate when issue #437
ships action-level cooldown (meta.cooldownMs) or proper effects-with-TTL.
The alert.* siblings (24 of them) also have this bug but are
non-impacting today because DISCORD_WEBHOOK_ALERTS is unset and
WorldEngineAlertPlugin drops them silently — fixing #437 will cover
both at the dispatcher level.

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GOAP actions with `effects: []` re-fired every planning cycle (~3s) while
their goal stayed violated. Two prod fires (PR #436, PR #451) had to
disable 6 actions before this lands. Restoring autonomy.

ActionDispatcherPlugin now honors `meta.cooldownMs` on every action. When
an action with a positive cooldownMs fires, the dispatcher records its
timestamp; subsequent dispatches of the same action id within the window
are dropped BEFORE the WIP queue and BEFORE the executor. Single chokepoint
covers both alert.* (FunctionExecutor → Discord) and action.* (DeepAgent /
A2A) paths. Drops log a fail-fast diagnostic with action id, age, and
remaining window, plus bump a new `cooldown_dropped` telemetry event.

Cooldown bucket is keyed on action id alone — per-target keying isn't
needed because each GOAP action targets one situation. Greenfield shape:
absence of `meta.cooldownMs` means "no cooldown" naturally, no flag.

Defaults applied to workspace/actions.yaml:
  - alert.*                          → 15 min
  - action.* with skillHint          → 30 min (agent work is expensive)
  - action.pr_*                      → 5 min  (remediation must stay responsive)
  - ceremony.*                       → 30 min (treated as skill dispatch)
  - action.dispatch_backmerge        → none (in-handler per-repo cooldown
                                              in pr-remediator stays authoritative)

Re-enables the 6 actions disabled by PRs #436 and #451:
alert.fleet_agent_stuck, action.fleet_incident_response,
action.fleet_downshift_models, action.fleet_investigate_orphaned_skills,
action.issues_triage_critical, action.issues_triage_bugs.

Tests:
  - action-dispatcher unit tests cover: blocks repeats within window;
    A's cooldown does not affect B; window expiry admits next dispatch;
    absent cooldownMs and cooldownMs<=0 mean no throttling.
  - End-to-end test spam-publishes 100 violations of fleet.no_skill_orphaned
    and asserts exactly 1 dispatch reaches the executor.
  - bun test: 1023 / 1023 pass.

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…roken (#454)

Audit of all 10 workspace/ceremonies/*.yaml against the live agent skill
registry surfaced 7 with skill-target mismatches that fail every fire:

ROUTED to correct skill owner:
  board.cleanup     skill=board_audit    targets [all] → [quinn]
  board.health      skill=board_health   targets [all] → [protomaker]
  daily-standup     skill=board_audit    targets [ava] → [quinn]
  health-check      skill=board_audit    targets [ava] → [quinn]

DISABLED (no agent advertises the skill):
  agent-health      skill=health_check   — no health_check anywhere
  board.retro       skill=pattern_analysis — no pattern_analysis anywhere
  service-health    skill=health_check   — same as agent-health

Quinn owns board_audit (and bug_triage / pr_review / qa_report).
Protomaker owns board_health (and 9 other apps/server skills).
The two `health_check`-keyed ceremonies were redundant anyway — the
agent_health and services world-state domains poll the same data every
60s and expose it on /api/agent-health and /api/services.

Re-enable the disabled three with a real skill if a periodic ANNOUNCE
to Discord is wanted (small `*_health_report` skill on protobot would
do it).

Workspace is bind-mounted, so the live container picks this up on
restart with no rebuild.

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The GOAP planner could dispatch a skill to a target agent (e.g.
auto-triage-sweep, user) where the named target wasn't in the live
ExecutorRegistry. The dispatcher fired anyway, the executor errored
404-style, and the failure cascaded into stuck work items + duplicate
incident filings (INC-003 through INC-018, ~93 work items in error
state). The cooldown work in #437 / #452 masked the symptom but the
structural gap remained.

ActionDispatcherPlugin now takes the shared ExecutorRegistry handle and
runs `_admitOrTargetUnresolved` immediately after the cooldown check and
BEFORE the WIP queue / executor. Same chokepoint pattern as cooldown:

  - target absent (skill-routed)            → admit
  - target = "all" (broadcast sentinel)     → admit
  - target matches a registration agentName → admit
  - target unresolvable                     → drop, log loud, telemetry
                                              bump `target_unresolved`

Drops surface action id, the unresolvable target, AND the agents that
DO exist so the routing mistake is immediately diagnosable. The only
opt-out is the broadcast sentinel "all" — there is no flag, no
enabled-bool. Greenfield-strict shape.

Wiring: src/index.ts passes the shared executorRegistry into the
dispatcher factory. Test fixtures that don't exercise target routing
omit the registry and the check is skipped — so existing tests stay
green without modification.

Audit of workspace/actions.yaml: only `protomaker` appears as an active
agentId target (twice). That agent is registered in workspace/agents.yaml.
The historical bad targets (`auto-triage-sweep`, `user`) were removed by
prior cleanup; this PR ensures any future regression fails closed.

Tests added in src/plugins/action-dispatcher-plugin.test.ts:
  - admits when target is registered
  - drops when target is unregistered + bumps `target_unresolved`
  - drops on mixed-target intent (single-target shape today via meta.agentId)
  - admits when target is the "all" broadcast sentinel
  - admits when meta.agentId is absent (skill-routed dispatch)
  - admits when no registry is wired (legacy test fixtures)

bun test: 1029 / 1029 pass.

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #415 fixed the hot-reload path so flipping a ceremony to disabled
cancelled its timer, but the initial-load path still added every YAML
entry (disabled or not) to the in-memory registry. Two consequences:

  1. External `ceremony.<id>.execute` triggers (from
     CeremonySkillExecutorPlugin's GOAP bridge) found the disabled
     ceremony in the registry and fired it anyway.
  2. After hot-reload flipped a ceremony enabled→disabled, the entry
     stayed in the registry — same external-trigger leak.

Fix: filter `enabled === false` at every place that lands a ceremony in
the registry (initial install, hot-reload new-file path, hot-reload
changed-file path). Disabled ceremonies are loaded by the YAML parser
(so the changed-file path can detect a flip) but never reach the
registry, never schedule a timer, and cannot be resurrected by an
external trigger. Operators see a `Skipping disabled ceremony: <id>`
log line for each skip — fail-loud per project convention.

Greenfield: no flag, no toggle. enabled:false means disabled
everywhere.

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mabry1985 and others added 11 commits April 20, 2026 16:08
13 TS2322 errors snuck through #452 and #455 because the CI test job
has been failing on type-check while build-and-push (the gate that
actually publishes :dev) is a separate workflow that runs on push.
Result: main/dev were green for container publish even though tsc
--noEmit was returning exit 2. Not visible in PR merge gates either
because test.conclusion=failure + build-and-push.conclusion=skipping
still resolved to a mergeable state.

Pattern of the 13 errors:

  bus.subscribe(T, "spy", (m) => requests.push(m));
                                  ^^^^^^^^^^^^^^^^
  Array.push() returns number, but the subscribe callback expects
  void | Promise<void>. Fix: wrap the body in a block so the arrow
  returns void:

  bus.subscribe(T, "spy", (m) => { requests.push(m); });

Applied via sed-style regex across both test files. 1029 tests still
pass (bun test). `bun run tsc --noEmit` now exits 0.

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… whitelist (closes #459) (#460)

AgentFleetHealthPlugin now takes an optional ExecutorRegistry (mirrors the
#455 wiring on ActionDispatcherPlugin). On each inbound autonomous.outcome,
`systemActor` is checked against `executorRegistry.list().map(r => r.agentName)`:

- Registered agent (ava, quinn, protomaker, ...) → aggregated in agents[]
  (existing shape, no behavior change).
- Anything else (pr-remediator, auto-triage-sweep, goap, user, ...) →
  routed to a separate systemActors[] bucket. No longer pollutes
  agentCount / maxFailureRate1h / orphanedSkillCount, so Ava's sitreps
  stop surfacing plugin names as "stuck agents".

First time a synthetic actor is seen, the plugin emits a one-time
console.warn naming the actor + skill (fail-fast and loud, per policy)
so operators know what's being filtered. No flag — same greenfield /
chokepoint discipline as #437 (cooldown) and #444 (target guard).

Scope note on `_default`: this plugin keys on outcome `systemActor`,
not registry `agentName`. `_default` only appears in /api/agent-health
(the registry-driven view). Nothing currently publishes an outcome with
`systemActor: "_default"`, so it doesn't reach agents[] here. If it
ever did, the new whitelist would drop it to systemActors[] — the right
outcome.

Verification plan (post-deploy):
  curl -s -X POST http://localhost:3000/v1/chat/completions \\
    -H 'Content-Type: application/json' \\
    -d '{"model":"ava","messages":[{"role":"user","content":"fleet sitrep"}]}'

Expected: no `pr-remediator`, `auto-triage-sweep`, or `user` in agents[].

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oard URL (#462)

The card at /.well-known/agent-card.json was advertising a URL like
http://ava:8081/a2a — host-mapped to the Astro dashboard, which 404s on
/a2a. Spec-compliant clients (@a2a-js/sdk and friends) doing card
discovery → POST to card.url could not reach the actual A2A endpoint;
the voice agent team papered over this by switching to the
/v1/chat/completions shim.

Fix: derive the card's `url` from variables that describe where the A2A
endpoint actually lives.

  1. WORKSTACEAN_PUBLIC_BASE_URL (e.g. https://ava.proto-labs.ai) →
     ${publicBase}/a2a. The canonical Cloudflare-fronted URL for
     external/Tailscale callers.
  2. Otherwise, http://${WORKSTACEAN_INTERNAL_HOST ?? "workstacean"}:${WORKSTACEAN_HTTP_PORT}/a2a
     — docker-network service name + the actual API port.

Also populate `additionalInterfaces` with the JSON-RPC transport at the
same URL so spec-compliant clients can pick deterministically. Drop the
WORKSTACEAN_BASE_URL coupling in the card builder — that variable
remains the externally-reachable URL stamped into A2A push-notification
callbacks (different concern, separate documentation).

Closes #461

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chore: back-merge main → dev (post v0.7.21 — fix merge-base)
…rdict (closes #465) (#469)

Two-layer fix per the issue body:

Layer A — Ava's diagnose_pr_stuck prompt (workspace/agents/ava.yaml):
The promotion-PR rule is now stated FIRST, above the four verdict
definitions. When the head is dev/staging OR base is main/staging OR
title starts "Promote"/"promote:", the verdict is always rebasable —
drift between branches is fixed with a back-merge of base into head,
not by splitting the PR. Phrased in positive framing per the
no-negative-reinforcement memory.

Layer B — code guard (lib/plugins/pr-remediator.ts):
New isPromotionPr() helper, called at the case "decomposable" handler
chokepoint before the close+comment path. On promotion PRs the guard
warns loudly (naming head/base/title), escalates to HITL via the
existing _emitStuckHitlEscalation pathway, and returns. PrDomainEntry
gains an optional headRef field; src/api/github.ts surfaces pr.head.ref
in the pr_pipeline domain so the guard can see it. _dispatchDiagnose
also adds "Head branch:" to the prompt payload so Ava sees the same
field.

Same defense-in-depth shape as #437 (cooldown), #444 (target registry),
#459 (synthetic actor filter): invariants live at the action-site
chokepoint, not at the planner. A drifting prompt cannot close a
release-pipeline PR.

Tests: 4 new cases in src/pr-remediator.test.ts cover refactor PR
(still closes), dev→main / staging→main / dev→staging promotion PRs
(escalate to HITL, do NOT close). Full suite 1047/1047 pass.

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…geError (#467 finding #2) (#470)

* fix(pr-remediator): guard ttlMs against Infinity/NaN with Number.isFinite

_extractHitlPolicy accepted any typeof "number" for ttlMs, which includes
Infinity and NaN. Both pass the typeof check but cause
new Date(Date.now() + ttlMs).toISOString() to throw a RangeError.

Fix: add Number.isFinite(p.ttlMs) && p.ttlMs > 0 guard.

Fixes finding #2 from GitHub issue #467 (CodeRabbit review on PR #466).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(plugins): gate pr-remediator-skill-executor with GitHub credential condition

`pr-remediator-skill-executor` was unconditionally installed (condition: () => true),
but `pr-remediator` itself is gated on GitHub credentials. When creds are absent,
dispatches to `pr.remediate.*` topics passed validation and the executor ran — but
no subscriber consumed them, resulting in silent success with no actual work done.

Fix: apply the same condition guard used by `pr-remediator`:
  !!(process.env.QUINN_APP_PRIVATE_KEY || process.env.GITHUB_TOKEN)

Resolves GitHub issue #467 (CodeRabbit finding #4 from PR #466).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…ors (closes #471) (#473)

Two bugs reported by protoVoice against ava.proto-labs.ai/a2a:

1. message/send with no metadata routed to protoBot (the router's
   default chat agent) instead of Ava. This endpoint is Ava's — the
   orchestrator card aggregates the fleet's skills, but routing defaults
   here must be Ava. Callers targeting another agent pass explicit
   metadata.targets. Logs an info line on the default path so operators
   can see the fallback firing.

2. When a downstream A2A sub-call was misrouted (e.g. upstream
   protoLabsAI/protoMaker#3536 — broken card URL), the raw HTML 404 page
   bubbled through the bus response's content field and was rendered as
   the assistant's reply text. BusAgentExecutor now detects HTML-looking
   payloads (<!DOCTYPE, <html, Cannot POST/GET, 404 Not Found), treats
   them as failures, logs the raw payload at warn level for debugging,
   and replaces the final message text with a sanitized operator-facing
   string that includes a short stripped debug hint.

No flag, no opt-out — greenfield, new behavior is the only behavior.

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the three open CodeRabbit findings from #466 not yet shipped
(items #2 and #4 already landed via #470 and 9792b0c).

#467 finding #1 (.claude/commands/rollcall.md):
  Hard-coded operator path /home/josh/dev/... → relative path
  scripts/agent-rollcall.sh with explicit "from repo root" guidance.
  Works for any clone; matches every other repo-script reference.

#467 finding #3 (README.md env table):
  Add a dedicated env-table row for WORKSTACEAN_INTERNAL_HOST so it's
  discoverable by anyone overriding the docker-network default.
  Cross-references WORKSTACEAN_PUBLIC_BASE_URL.

#467 finding #5 (src/index.ts startup-validator):
  validateActionExecutors() ran BEFORE loadWorkspacePlugins(), so
  executor registrars shipped as workspace plugins were falsely flagged
  in strict mode. It also ran only once at startup, so config.reload of
  actions.yaml bypassed the fail-loud guard.

  Fix: extract a runWiringValidator(reason) helper, call it AFTER all
  plugin loading (core + registered + workspace), and re-run inside the
  CONFIG_RELOAD subscriber after loadActionsYaml(). New "[reload-validator]"
  log tag distinguishes the call sites.

Tests: 1054/1054 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(467): rollcall path + INTERNAL_HOST env doc + validator after workspace plugins
… (v0.7.22 candidate, re-cut) (#466) (#468)

* fix: extension URIs use proto-labs.ai (not protolabs.ai) (#407)

All 27 references to https://protolabs.ai/a2a/ext/* changed to
https://proto-labs.ai/a2a/ext/* to match the actual domain. These
URIs are opaque identifiers (not published specs today) but should
reference a domain we own.

Breaking: external agents (Quinn, protoPen) whose cards declare the
old URI will stop matching the registry until they update. Filed on
Quinn to update her card.




* chore(release): bump to v0.7.20 (#408)



* chore: remove protoaudio + protovoice from agent rollcall (#410)

Both services decommissioned. Containers stopped + removed.
Only reference in protoWorkstacean was the rollcall script.

Note: homelab-iac/stacks/ai/docker-compose.yml still has a
worldmonitor network reference at line 521 + service at line 833.
Needs separate cleanup in that repo.




* feat: upgrade web_search → searxng_search + give Ava fleet health tools (#411)

Two changes:

1. Replace the basic `web_search` tool (5 results, hardcoded engines)
   with `searxng_search` — adapted from rabbit-hole.io's full-surface
   SearXNG integration. New capabilities:
   - Category routing: general, news, science, it
   - Time range filtering: day, week, month, year
   - Bang syntax: !wp (Wikipedia), !scholar, !gh (GitHub)
   - Infoboxes, direct answers, suggestions in response
   - Configurable max_results (default 10, was 5)

   Updated in both bus-tools.ts (@protolabsai/sdk pattern) and
   deep-agent-executor.ts (LangChain pattern).

2. Give Ava three fleet health tools she was missing:
   - get_ci_health — CI success rates across repos
   - get_pr_pipeline — open PRs, conflicts, staleness
   - get_incidents — security/ops incidents

   Ava can now answer fleet health questions directly instead of
   always delegating to Quinn.

Ava's tool count: 10 → 13. Tool rename: web_search → searxng_search
(greenfield, no backward compat alias).




* chore(projects): register protoAgent in projects.yaml (#414)

protoAgent is the new GitHub Template repo that replaces per-agent
A2A bootstrapping. Registers it as an active dev project owned by
Quinn, matching the shape of existing entries. Plane / GitHub
webhook / Discord provisioning remain TODO — those integrations
aren't configured in this deployment, so the onboard plugin
skipped them.




* chore(release): bump to v0.7.21 (#413)



* feat(ava): expand helm toolset, wire GOAP skills, fix ceremony disable bug (#415)

Ava agent audit + overhaul:
- Tools: 10 → 22 (direct observation, propose_config_change, incident reporting)
- Skills: 3 → 7 (debug_ci_failures, fleet_incident_response, downshift_models, investigate_orphaned_skills)
- System prompt rewritten: self-improvement instructions, escalation policy, GOAP-dispatch playbook
- DeepAgentExecutor now applies skill-level systemPromptOverride (goal_proposal, diagnose_pr_stuck)
- Fix ceremony loader bug: disabled ceremonies were filtered out, preventing hot-reload from cancelling timers
- Clean up board.pr-audit.yaml (remove spurious action field, restore schedule, keep disabled)
- Update docs: README, deep-agent runtime, agent-skills reference, self-improving loop




* fix(pr-remediator): close dispatch gap — self-dispatch + broaden auto-approve (#417)

Two root causes prevented PRs from being auto-merged:

1. Dispatch gap: tier_0 short-circuit in ActionDispatcherPlugin completed
   all actions immediately without dispatching to agent.skill.request.
   Every action in actions.yaml is tier_0, so the fireAndForget path
   (which publishes the skill request) was unreachable dead code.
   Fix: tier_0 now falls through when meta.fireAndForget is set.

2. Approval gap: readyToMerge requires reviewState=approved, but
   auto-approve only covered dependabot/renovate/promote:/chore(deps.
   Human PRs, release PRs (chore(release), and github-actions PRs
   all lacked approved reviews and sat indefinitely.
   Fix: added app/github-actions to authors, chore(release, chore:,
   docs( to safe title prefixes.

Additionally, PrRemediatorPlugin now self-dispatches remediation on
every world.state.updated tick — checking for readyToMerge, dirty,
failingCi, and changesRequested PRs directly from cached domain data.
This removes the dependency on GOAP dispatch reaching the plugin via
pr.remediate.* topics (which were never published in production after
Arc 1.4 removed meta.topic routing).




* feat(goap): issue_zero domain + goals — track open GitHub issues across fleet (#419)

Adds a github_issues domain that polls /repos/{repo}/issues?state=open
for all managed projects and classifies by label (critical, bug,
enhancement). Three GOAP goals enforce issue hygiene:

  - issues.zero_critical (critical severity, max: 0)
  - issues.zero_bugs (high severity, max: 0)
  - issues.total_low (medium severity, max: 5)

Each goal has a matching alert action and a triage dispatch action
that invokes Ava's new issue_triage skill. The skill instructs Ava
to resolve, convert to board features, delegate, or close issues
with rationale — driving toward zero open issues across all repos.

Domain polls every 5 minutes (issue velocity is low, GitHub rate
limits are a concern with 6+ repos).




* feat: manage_board list action (#247) + a2a.trace extension (#359) (#420)

Two enhancements to reach issue zero:

manage_board list (#247):
  - Added GET /api/board/features/list endpoint proxying to Studio
  - Added "list" action to manage_board tool with status filter
  - Ava can now query "show me all blocked features" directly

a2a.trace extension (#359):
  - New langfuse-trace extension stamps a2a.trace metadata on all
    outbound A2A dispatches (traceId, callerAgent, skill, project)
  - Quinn reads this to link Langfuse traces across agent boundaries
  - Registered at startup alongside cost/confidence/blast extensions

Closes #247, closes #359.




* fix(pr-remediator): case-insensitive auto-approve prefix matching (#421)

* feat: manage_board list action (#247) + a2a.trace extension (#359)

Two enhancements to reach issue zero:

manage_board list (#247):
  - Added GET /api/board/features/list endpoint proxying to Studio
  - Added "list" action to manage_board tool with status filter
  - Ava can now query "show me all blocked features" directly

a2a.trace extension (#359):
  - New langfuse-trace extension stamps a2a.trace metadata on all
    outbound A2A dispatches (traceId, callerAgent, skill, project)
  - Quinn reads this to link Langfuse traces across agent boundaries
  - Registered at startup alongside cost/confidence/blast extensions

Closes #247, closes #359.



* fix(pr-remediator): case-insensitive auto-approve prefix matching

"Promote dev to main" titles start with capital P, but the prefix
check was case-sensitive against "promote:". Now lowercases the
title before matching so both "promote:" and "Promote" patterns
are caught.



---------




* fix(skill-dispatcher): wire alert.* executors + startup validator (#426) (#427)

Closes the structural gap where 6+ tier_0 fire-and-forget alert skills had
no registered executor, causing SkillDispatcherPlugin to log "No executor
found" and silently drop the dispatch on every GOAP planning cycle.

- AlertSkillExecutorPlugin registers FunctionExecutors for all 24 bare
  alert.* actions in workspace/actions.yaml. Each translates the dispatch
  into a structured message.outbound.discord.alert event consumed by the
  existing WorldEngineAlertPlugin webhook routing.
- validate-action-executors.ts cross-checks the loaded ActionRegistry
  against the live ExecutorRegistry at startup. Surfaces every gap as a
  HIGH-severity Discord alert (goal platform.skills_unwired) and a loud
  console.error. Set WORKSTACEAN_STRICT_WIRING=1 to crash startup instead.
- action.issues_triage_bugs already routes correctly via meta.agentId=ava
  to the existing DeepAgentExecutor for Ava's issue_triage skill — no
  duplicate wiring needed (greenfield).




* fix(ceremonies): stop world.state.# leak from ceremony snapshots (#428)

CeremonyStateExtension was publishing { domain, data } envelopes on
`world.state.snapshot` after every ceremony completion. GoalEvaluatorPlugin
subscribes to `world.state.#`, treated the malformed payload as a WorldState,
and emitted a "Selector ... not found" violation for every loaded goal on
every ceremony tick (the cluster of 25+ violations at each :15/:30 boundary
in the live container logs). All listed selectors (flow.efficiency.ratio,
services.discord.connected, agent_health.agentCount, etc.) actually exist
in the producer output — the goals are correct.

Changes:
- Move ceremony snapshot publish to `ceremony.state.snapshot` (off the
  world.state.# namespace). Leaves the existing CeremoniesState shape and
  consumers unchanged.
- Goal evaluator: defensive payload shape check. Reject single-domain
  envelopes ({ domain, data }) and other non-WorldState payloads loud-once
  instead of generating one violation per goal.
- Goal evaluator: startup selector validator. After the first valid world
  state arrives, walk every loaded goal's selector and HIGH-log any that
  doesn't resolve. Re-armed on goals.reload / config.reload so drift caught
  by future hot-reloads also surfaces.
- Tests: regression guard that CeremonyStateExtension does not publish on
  world.state.#; goal evaluator ignores malformed payloads; validator
  catches an intentionally broken selector.

Closes #424




* fix(rollcall): point /rollcall skill at in-repo script (closes #425) (#429)

The Claude Code skill was calling the homelab-iac copy of agent-rollcall.sh,
which had drifted from this repo's copy. The in-repo script knows about
the in-process DeepAgent runtime (Ava, protoBot, Tuner) and the current
A2A fleet; the homelab-iac copy still probed for the archived ava-agent
container and the deprecated protoaudio/protovoice services.

Single source of truth: this repo. The homelab-iac copy was separately
synced in homelab-iac@64e8dcf.




* Promote dev to main (v0.7.21) (#418) (#423)

* fix: extension URIs use proto-labs.ai (not protolabs.ai) (#407)

All 27 references to https://protolabs.ai/a2a/ext/* changed to
https://proto-labs.ai/a2a/ext/* to match the actual domain. These
URIs are opaque identifiers (not published specs today) but should
reference a domain we own.

Breaking: external agents (Quinn, protoPen) whose cards declare the
old URI will stop matching the registry until they update. Filed on
Quinn to update her card.




* chore(release): bump to v0.7.20 (#408)



* chore: remove protoaudio + protovoice from agent rollcall (#410)

Both services decommissioned. Containers stopped + removed.
Only reference in protoWorkstacean was the rollcall script.

Note: homelab-iac/stacks/ai/docker-compose.yml still has a
worldmonitor network reference at line 521 + service at line 833.
Needs separate cleanup in that repo.




* feat: upgrade web_search → searxng_search + give Ava fleet health tools (#411)

Two changes:

1. Replace the basic `web_search` tool (5 results, hardcoded engines)
   with `searxng_search` — adapted from rabbit-hole.io's full-surface
   SearXNG integration. New capabilities:
   - Category routing: general, news, science, it
   - Time range filtering: day, week, month, year
   - Bang syntax: !wp (Wikipedia), !scholar, !gh (GitHub)
   - Infoboxes, direct answers, suggestions in response
   - Configurable max_results (default 10, was 5)

   Updated in both bus-tools.ts (@protolabsai/sdk pattern) and
   deep-agent-executor.ts (LangChain pattern).

2. Give Ava three fleet health tools she was missing:
   - get_ci_health — CI success rates across repos
   - get_pr_pipeline — open PRs, conflicts, staleness
   - get_incidents — security/ops incidents

   Ava can now answer fleet health questions directly instead of
   always delegating to Quinn.

Ava's tool count: 10 → 13. Tool rename: web_search → searxng_search
(greenfield, no backward compat alias).




* chore(projects): register protoAgent in projects.yaml (#414)

protoAgent is the new GitHub Template repo that replaces per-agent
A2A bootstrapping. Registers it as an active dev project owned by
Quinn, matching the shape of existing entries. Plane / GitHub
webhook / Discord provisioning remain TODO — those integrations
aren't configured in this deployment, so the onboard plugin
skipped them.




* chore(release): bump to v0.7.21 (#413)



* feat(ava): expand helm toolset, wire GOAP skills, fix ceremony disable bug (#415)

Ava agent audit + overhaul:
- Tools: 10 → 22 (direct observation, propose_config_change, incident reporting)
- Skills: 3 → 7 (debug_ci_failures, fleet_incident_response, downshift_models, investigate_orphaned_skills)
- System prompt rewritten: self-improvement instructions, escalation policy, GOAP-dispatch playbook
- DeepAgentExecutor now applies skill-level systemPromptOverride (goal_proposal, diagnose_pr_stuck)
- Fix ceremony loader bug: disabled ceremonies were filtered out, preventing hot-reload from cancelling timers
- Clean up board.pr-audit.yaml (remove spurious action field, restore schedule, keep disabled)
- Update docs: README, deep-agent runtime, agent-skills reference, self-improving loop




* fix(pr-remediator): close dispatch gap — self-dispatch + broaden auto-approve (#417)

Two root causes prevented PRs from being auto-merged:

1. Dispatch gap: tier_0 short-circuit in ActionDispatcherPlugin completed
   all actions immediately without dispatching to agent.skill.request.
   Every action in actions.yaml is tier_0, so the fireAndForget path
   (which publishes the skill request) was unreachable dead code.
   Fix: tier_0 now falls through when meta.fireAndForget is set.

2. Approval gap: readyToMerge requires reviewState=approved, but
   auto-approve only covered dependabot/renovate/promote:/chore(deps.
   Human PRs, release PRs (chore(release), and github-actions PRs
   all lacked approved reviews and sat indefinitely.
   Fix: added app/github-actions to authors, chore(release, chore:,
   docs( to safe title prefixes.

Additionally, PrRemediatorPlugin now self-dispatches remediation on
every world.state.updated tick — checking for readyToMerge, dirty,
failingCi, and changesRequested PRs directly from cached domain data.
This removes the dependency on GOAP dispatch reaching the plugin via
pr.remediate.* topics (which were never published in production after
Arc 1.4 removed meta.topic routing).




* feat(goap): issue_zero domain + goals — track open GitHub issues across fleet (#419)

Adds a github_issues domain that polls /repos/{repo}/issues?state=open
for all managed projects and classifies by label (critical, bug,
enhancement). Three GOAP goals enforce issue hygiene:

  - issues.zero_critical (critical severity, max: 0)
  - issues.zero_bugs (high severity, max: 0)
  - issues.total_low (medium severity, max: 5)

Each goal has a matching alert action and a triage dispatch action
that invokes Ava's new issue_triage skill. The skill instructs Ava
to resolve, convert to board features, delegate, or close issues
with rationale — driving toward zero open issues across all repos.

Domain polls every 5 minutes (issue velocity is low, GitHub rate
limits are a concern with 6+ repos).




* feat: manage_board list action (#247) + a2a.trace extension (#359) (#420)

Two enhancements to reach issue zero:

manage_board list (#247):
  - Added GET /api/board/features/list endpoint proxying to Studio
  - Added "list" action to manage_board tool with status filter
  - Ava can now query "show me all blocked features" directly

a2a.trace extension (#359):
  - New langfuse-trace extension stamps a2a.trace metadata on all
    outbound A2A dispatches (traceId, callerAgent, skill, project)
  - Quinn reads this to link Langfuse traces across agent boundaries
  - Registered at startup alongside cost/confidence/blast extensions

Closes #247, closes #359.




* fix(pr-remediator): case-insensitive auto-approve prefix matching (#421)

* feat: manage_board list action (#247) + a2a.trace extension (#359)

Two enhancements to reach issue zero:

manage_board list (#247):
  - Added GET /api/board/features/list endpoint proxying to Studio
  - Added "list" action to manage_board tool with status filter
  - Ava can now query "show me all blocked features" directly

a2a.trace extension (#359):
  - New langfuse-trace extension stamps a2a.trace metadata on all
    outbound A2A dispatches (traceId, callerAgent, skill, project)
  - Quinn reads this to link Langfuse traces across agent boundaries
  - Registered at startup alongside cost/confidence/blast extensions

Closes #247, closes #359.



* fix(pr-remediator): case-insensitive auto-approve prefix matching

"Promote dev to main" titles start with capital P, but the prefix
check was case-sensitive against "promote:". Now lowercases the
title before matching so both "promote:" and "Promote" patterns
are caught.



---------




---------








* feat(ceremony): wire ceremony.security_triage + ceremony.service_health_discord (#431)

Adds CeremonySkillExecutorPlugin — registers FunctionExecutors that bridge
GOAP `ceremony.*` actions to the matching `ceremony.<id>.execute` topic
CeremonyPlugin already listens for. Without this bridge,
SkillDispatcherPlugin dropped every dispatch with "No executor found …"
and (post-#427) emitted HIGH platform.skills_unwired alerts every cycle.

Mirrors the alert-skill-executor-plugin pattern from #427 — explicit
action→ceremony id mapping, install order matters (after registry,
before skill-dispatcher).

Partial fix for #430.




* fix(pr-remediator): wire 5 GOAP-dispatched skills + honor hitlPolicy (#432)

Closes the structural gap where 5 actions in workspace/actions.yaml route
to handlers in PrRemediatorPlugin but had no registered executor:
  - action.pr_update_branch    → pr.remediate.update_branch
  - action.pr_merge_ready      → pr.remediate.merge_ready
  - action.pr_fix_ci           → pr.remediate.fix_ci
  - action.pr_address_feedback → pr.remediate.address_feedback
  - action.dispatch_backmerge  → pr.backmerge.dispatch

Before this change SkillDispatcherPlugin logged "No executor found" and
dropped the dispatch every GOAP cycle. After PR #427's startup validator
the same gap raised platform.skills_unwired HIGH every tick.

Wiring follows the AlertSkillExecutorPlugin pattern from #427:
  - PrRemediatorSkillExecutorPlugin registers FunctionExecutors that
    publish on the existing pr-remediator subscription topics, keeping
    "bus is the contract" — no plugin holds a reference to the other.
  - Executors are fire-and-forget per actions.yaml meta. They return a
    successful SkillResult immediately; pr-remediator's handler runs
    asynchronously on the bus subscription.
  - Install order matches alert-skill-executor: AFTER ExecutorRegistry
    construction, BEFORE skill-dispatcher.

For action.pr_merge_ready specifically, the meta.hitlPolicy
(ttlMs: 1800000, onTimeout: approve) is now honoured. The executor
forwards meta into the trigger payload; _handleMergeReady extracts it
via _extractHitlPolicy and passes it to _emitHitlApproval, which
populates HITLRequest.{ttlMs, onTimeout}. HITLPlugin already auto-
publishes a synthetic approve response when onTimeout=approve fires.

Closes part of #430. Ceremony + protoMaker actions ship in a separate PR.




* feat(agents): register protomaker A2A agent (closes part of #430) (#433)

protoMaker (apps/server in protoLabsAI/ava) has been A2A-ready for a while —
serves agent-card.json with 10 skills including the two referenced by
unwired GOAP actions:
  - action.protomaker_triage_blocked → skill board_health
  - action.protomaker_start_auto_mode → skill auto_mode

Both actions targeted [protomaker], but no agent named "protomaker" was
registered, so the dispatcher couldn't route. Adding the entry closes
the routing gap; A2AExecutor's existing target-matching does the rest.

Endpoint: http://automaker-server:3008/a2a (verified from inside the
workstacean container with AVA_API_KEY → JSON-RPC 2.0 response).




* fix(agents): drop overscoped subscribesTo from protomaker entry (#434)

#433 added `subscribesTo: message.inbound.github.#` to the new protomaker
agent registration, copy-pasted from quinn's pattern. That was wrong:
protoMaker is reached via explicit GOAP `targets: [protomaker]` dispatches
(action.protomaker_triage_blocked, action.protomaker_start_auto_mode), not
as a broadcast inbound listener.

Quinn already subscribes to all GitHub inbound and dispatches `bug_triage`
on protoMaker's behalf. Having protomaker subscribe to the same broadcast
topic is one of the contributing paths to the duplicate-triage spam loop
filed as protoLabsAI/protoMaker#3503 (the root cause is Quinn's handler
not being idempotent — but this cleanup removes one extra firing path).




* fix(ava): rename web_search → searxng_search to match runtime tool name (#435)

PR #411 renamed the tool from web_search to searxng_search in
src/agent-runtime/tools/bus-tools.ts (line 393), but ava.yaml still
declared the old name. Result: at startup the runtime warns
"agent ava declares unknown tools: web_search" and Ava ends up with no
search capability — when asked, she explicitly responds "I don't have a
searxng_search or web_search tool in my current toolkit."

This is the config side of the half-finished rename that PR #411 missed.
After this lands and workstacean restarts, Ava's toolkit should include
searxng_search and the unknown-tools warning should be empty for her.




* fix(goap): disable fleet_agent_stuck loop — fires every cycle, no dedup (#436)

Two GOAP actions on goal fleet.no_agent_stuck were spamming Discord
because they had no `effects` and no cooldown:

  - alert.fleet_agent_stuck → posts a Discord alert
  - action.fleet_incident_response → dispatches Ava to file an incident,
    page on-call, and pause routing

Observed 2026-04-20: when auto-triage-sweep hit 100% failure rate on
bug_triage (cascading from the non-idempotent handler in
protoLabsAI/protoMaker#3503), GOAP re-fired both actions every planning
cycle. Ava filed INC-003 through INC-009 in ~30 seconds, each posting to
Discord. The pause routing succeeded but the rolling 1h failure rate
metric doesn't drop instantly, so the goal stayed violated and the loop
kept re-firing.

Disabling both actions until proper dedup lands. Reinstate when:
  1. action.fleet_incident_response gains a cooldown OR an `effects`
     marker that satisfies the goal for the cooldown window
  2. Ava's fleet_incident_response skill checks for an existing open
     incident on the same agent before filing a new one
  3. alert.fleet_agent_stuck gains per-agent rate limiting




* fix(goap): disable 4 more action.* loops — same no-cooldown bug as #436 (#451)

After #436 disabled action.fleet_incident_response, observed similar
loop spam from FOUR more action.* dispatches that share the same
architectural bug (no cooldown, no satisfying effects → re-fire every
GOAP cycle while goal stays violated):

  action.fleet_investigate_orphaned_skills  — Ava posts orphaned-skill
                                               diagnosis to Discord ops
                                               on every cycle (8+ posts
                                               in 1 min observed)
  action.issues_triage_critical             — ~447 fires in 30 min
  action.issues_triage_bugs                 — ~447 fires in 30 min;
                                               compounds with #3503
                                               by re-triaging same issues
  action.fleet_downshift_models             — same pattern when cost
                                               exceeds budget

ALL share the same pattern as the alert.* actions and the original
fleet_incident_response: effects: [] + no cooldownMs + persistent goal
violation = infinite re-fire.

Mitigation only — Ava temporarily can't auto-investigate orphaned
skills or auto-triage new GitHub issues. Reinstate when issue #437
ships action-level cooldown (meta.cooldownMs) or proper effects-with-TTL.
The alert.* siblings (24 of them) also have this bug but are
non-impacting today because DISCORD_WEBHOOK_ALERTS is unset and
WorldEngineAlertPlugin drops them silently — fixing #437 will cover
both at the dispatcher level.




* feat(goap): per-action cooldown in ActionDispatcher (closes #437) (#452)

GOAP actions with `effects: []` re-fired every planning cycle (~3s) while
their goal stayed violated. Two prod fires (PR #436, PR #451) had to
disable 6 actions before this lands. Restoring autonomy.

ActionDispatcherPlugin now honors `meta.cooldownMs` on every action. When
an action with a positive cooldownMs fires, the dispatcher records its
timestamp; subsequent dispatches of the same action id within the window
are dropped BEFORE the WIP queue and BEFORE the executor. Single chokepoint
covers both alert.* (FunctionExecutor → Discord) and action.* (DeepAgent /
A2A) paths. Drops log a fail-fast diagnostic with action id, age, and
remaining window, plus bump a new `cooldown_dropped` telemetry event.

Cooldown bucket is keyed on action id alone — per-target keying isn't
needed because each GOAP action targets one situation. Greenfield shape:
absence of `meta.cooldownMs` means "no cooldown" naturally, no flag.

Defaults applied to workspace/actions.yaml:
  - alert.*                          → 15 min
  - action.* with skillHint          → 30 min (agent work is expensive)
  - action.pr_*                      → 5 min  (remediation must stay responsive)
  - ceremony.*                       → 30 min (treated as skill dispatch)
  - action.dispatch_backmerge        → none (in-handler per-repo cooldown
                                              in pr-remediator stays authoritative)

Re-enables the 6 actions disabled by PRs #436 and #451:
alert.fleet_agent_stuck, action.fleet_incident_response,
action.fleet_downshift_models, action.fleet_investigate_orphaned_skills,
action.issues_triage_critical, action.issues_triage_bugs.

Tests:
  - action-dispatcher unit tests cover: blocks repeats within window;
    A's cooldown does not affect B; window expiry admits next dispatch;
    absent cooldownMs and cooldownMs<=0 mean no throttling.
  - End-to-end test spam-publishes 100 violations of fleet.no_skill_orphaned
    and asserts exactly 1 dispatch reaches the executor.
  - bun test: 1023 / 1023 pass.




* fix(ceremonies): route 4 ceremonies to right skill owner, disable 3 broken (#454)

Audit of all 10 workspace/ceremonies/*.yaml against the live agent skill
registry surfaced 7 with skill-target mismatches that fail every fire:

ROUTED to correct skill owner:
  board.cleanup     skill=board_audit    targets [all] → [quinn]
  board.health      skill=board_health   targets [all] → [protomaker]
  daily-standup     skill=board_audit    targets [ava] → [quinn]
  health-check      skill=board_audit    targets [ava] → [quinn]

DISABLED (no agent advertises the skill):
  agent-health      skill=health_check   — no health_check anywhere
  board.retro       skill=pattern_analysis — no pattern_analysis anywhere
  service-health    skill=health_check   — same as agent-health

Quinn owns board_audit (and bug_triage / pr_review / qa_report).
Protomaker owns board_health (and 9 other apps/server skills).
The two `health_check`-keyed ceremonies were redundant anyway — the
agent_health and services world-state domains poll the same data every
60s and expose it on /api/agent-health and /api/services.

Re-enable the disabled three with a real skill if a periodic ANNOUNCE
to Discord is wanted (small `*_health_report` skill on protobot would
do it).

Workspace is bind-mounted, so the live container picks this up on
restart with no rebuild.




* feat(goap): pre-dispatch target registry guard (closes #444) (#455)

The GOAP planner could dispatch a skill to a target agent (e.g.
auto-triage-sweep, user) where the named target wasn't in the live
ExecutorRegistry. The dispatcher fired anyway, the executor errored
404-style, and the failure cascaded into stuck work items + duplicate
incident filings (INC-003 through INC-018, ~93 work items in error
state). The cooldown work in #437 / #452 masked the symptom but the
structural gap remained.

ActionDispatcherPlugin now takes the shared ExecutorRegistry handle and
runs `_admitOrTargetUnresolved` immediately after the cooldown check and
BEFORE the WIP queue / executor. Same chokepoint pattern as cooldown:

  - target absent (skill-routed)            → admit
  - target = "all" (broadcast sentinel)     → admit
  - target matches a registration agentName → admit
  - target unresolvable                     → drop, log loud, telemetry
                                              bump `target_unresolved`

Drops surface action id, the unresolvable target, AND the agents that
DO exist so the routing mistake is immediately diagnosable. The only
opt-out is the broadcast sentinel "all" — there is no flag, no
enabled-bool. Greenfield-strict shape.

Wiring: src/index.ts passes the shared executorRegistry into the
dispatcher factory. Test fixtures that don't exercise target routing
omit the registry and the check is skipped — so existing tests stay
green without modification.

Audit of workspace/actions.yaml: only `protomaker` appears as an active
agentId target (twice). That agent is registered in workspace/agents.yaml.
The historical bad targets (`auto-triage-sweep`, `user`) were removed by
prior cleanup; this PR ensures any future regression fails closed.

Tests added in src/plugins/action-dispatcher-plugin.test.ts:
  - admits when target is registered
  - drops when target is unregistered + bumps `target_unresolved`
  - drops on mixed-target intent (single-target shape today via meta.agentId)
  - admits when target is the "all" broadcast sentinel
  - admits when meta.agentId is absent (skill-routed dispatch)
  - admits when no registry is wired (legacy test fixtures)

bun test: 1029 / 1029 pass.




* fix(ceremony): honor enabled:false on initial load (closes #453) (#456)

PR #415 fixed the hot-reload path so flipping a ceremony to disabled
cancelled its timer, but the initial-load path still added every YAML
entry (disabled or not) to the in-memory registry. Two consequences:

  1. External `ceremony.<id>.execute` triggers (from
     CeremonySkillExecutorPlugin's GOAP bridge) found the disabled
     ceremony in the registry and fired it anyway.
  2. After hot-reload flipped a ceremony enabled→disabled, the entry
     stayed in the registry — same external-trigger leak.

Fix: filter `enabled === false` at every place that lands a ceremony in
the registry (initial install, hot-reload new-file path, hot-reload
changed-file path). Disabled ceremonies are loaded by the YAML parser
(so the changed-file path can detect a flip) but never reach the
registry, never schedule a timer, and cannot be resurrected by an
external trigger. Operators see a `Skipping disabled ceremony: <id>`
log line for each skip — fail-loud per project convention.

Greenfield: no flag, no toggle. enabled:false means disabled
everywhere.




* fix(tests): wrap subscribe-spy push callbacks to return void (#457)

13 TS2322 errors snuck through #452 and #455 because the CI test job
has been failing on type-check while build-and-push (the gate that
actually publishes :dev) is a separate workflow that runs on push.
Result: main/dev were green for container publish even though tsc
--noEmit was returning exit 2. Not visible in PR merge gates either
because test.conclusion=failure + build-and-push.conclusion=skipping
still resolved to a mergeable state.

Pattern of the 13 errors:

  bus.subscribe(T, "spy", (m) => requests.push(m));
                                  ^^^^^^^^^^^^^^^^
  Array.push() returns number, but the subscribe callback expects
  void | Promise<void>. Fix: wrap the body in a block so the arrow
  returns void:

  bus.subscribe(T, "spy", (m) => { requests.push(m); });

Applied via sed-style regex across both test files. 1029 tests still
pass (bun test). `bun run tsc --noEmit` now exits 0.




* fix(fleet-health): filter outcomes from synthetic actors via registry whitelist (closes #459) (#460)

AgentFleetHealthPlugin now takes an optional ExecutorRegistry (mirrors the
#455 wiring on ActionDispatcherPlugin). On each inbound autonomous.outcome,
`systemActor` is checked against `executorRegistry.list().map(r => r.agentName)`:

- Registered agent (ava, quinn, protomaker, ...) → aggregated in agents[]
  (existing shape, no behavior change).
- Anything else (pr-remediator, auto-triage-sweep, goap, user, ...) →
  routed to a separate systemActors[] bucket. No longer pollutes
  agentCount / maxFailureRate1h / orphanedSkillCount, so Ava's sitreps
  stop surfacing plugin names as "stuck agents".

First time a synthetic actor is seen, the plugin emits a one-time
console.warn naming the actor + skill (fail-fast and loud, per policy)
so operators know what's being filtered. No flag — same greenfield /
chokepoint discipline as #437 (cooldown) and #444 (target guard).

Scope note on `_default`: this plugin keys on outcome `systemActor`,
not registry `agentName`. `_default` only appears in /api/agent-health
(the registry-driven view). Nothing currently publishes an outcome with
`systemActor: "_default"`, so it doesn't reach agents[] here. If it
ever did, the new whitelist would drop it to systemActors[] — the right
outcome.

Verification plan (post-deploy):
  curl -s -X POST http://localhost:3000/v1/chat/completions \\
    -H 'Content-Type: application/json' \\
    -d '{"model":"ava","messages":[{"role":"user","content":"fleet sitrep"}]}'

Expected: no `pr-remediator`, `auto-triage-sweep`, or `user` in agents[].




* fix(a2a): agent card advertises canonical A2A endpoint, not the dashboard URL (#462)

The card at /.well-known/agent-card.json was advertising a URL like
http://ava:8081/a2a — host-mapped to the Astro dashboard, which 404s on
/a2a. Spec-compliant clients (@a2a-js/sdk and friends) doing card
discovery → POST to card.url could not reach the actual A2A endpoint;
the voice agent team papered over this by switching to the
/v1/chat/completions shim.

Fix: derive the card's `url` from variables that describe where the A2A
endpoint actually lives.

  1. WORKSTACEAN_PUBLIC_BASE_URL (e.g. https://ava.proto-labs.ai) →
     ${publicBase}/a2a. The canonical Cloudflare-fronted URL for
     external/Tailscale callers.
  2. Otherwise, http://${WORKSTACEAN_INTERNAL_HOST ?? "workstacean"}:${WORKSTACEAN_HTTP_PORT}/a2a
     — docker-network service name + the actual API port.

Also populate `additionalInterfaces` with the JSON-RPC transport at the
same URL so spec-compliant clients can pick deterministically. Drop the
WORKSTACEAN_BASE_URL coupling in the card builder — that variable
remains the externally-reachable URL stamped into A2A push-notification
callbacks (different concern, separate documentation).

Closes #461




---------

Co-authored-by: Josh Mabry <31560031+mabry1985@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Josh <artificialcitizens@gmail.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 22, 2026

📝 Walkthrough

Walkthrough

Adds three new executor plugins (alert-skill, ceremony-skill, pr-remediator) to handle previously unexecuted GOAP skills, implements startup and live wiring validation to catch missing executors, enhances PR remediator with promotion-PR detection and HITL policy forwarding, adds HTML error sanitization in A2A server, and updates GitHub API responses to include head branch references.

Changes

Cohort / File(s) Summary
Documentation & Configuration
.claude/commands/rollcall.md, README.md, workspace/agents/ava.yaml
Updated rollcall command to use relative path and documented new environment variables (WORKSTACEAN_PUBLIC_BASE_URL, WORKSTACEAN_INTERNAL_HOST). Renamed Ava's web search tool from web_search to searxng_search and added promotion-PR rule to diagnose verdict logic.
Plugin Registry & Wiring Validation
src/index.ts
Registered three new executor plugins: alert-skill-executor, ceremony-skill-executor, and pr-remediator-skill-executor. Updated existing plugins to accept executorRegistry. Added fail-loud validateActionExecutors validation pass at startup and on every config reload, with strict mode gating via WORKSTACEAN_STRICT_WIRING.
PR Remediator Enhancement
lib/plugins/pr-remediator.ts
Extended PR entry with optional headRef field. Added promotion-PR detection via isPromotionPr() checking protected branches and title prefixes. Implemented HITL policy extraction from meta.hitlPolicy with ttlMs and onTimeout validation. Added escalation flow for promotion PRs instead of decomposable close+split.
PR Remediator Tests
src/pr-remediator.test.ts
Updated test fixtures with headRef support. Added comprehensive test coverage for decomposable verdict handling including promotion-PR guard scenarios and HITL policy propagation with fallback behavior.
A2A Server Error Handling
src/api/a2a-server.ts, src/api/a2a-server.test.ts
Added HTML error detection and sanitization helpers to identify upstream error pages. Set default targets: ["ava"] when metadata omits targets. Sanitize and log HTML errors instead of passing raw markup to downstream callers. Added four new Phase 7 integration tests validating error handling and target routing.
GitHub API Response Shape
src/api/github.ts
Extended PR pipeline response to include headRef (branch name) alongside existing headSha and baseRef fields. Updated internal PrListItem typing to reflect expanded head object structure in GitHub response payload.

Sequence Diagram

sequenceDiagram
    participant Startup as Startup Process
    participant PluginMgr as Plugin Manager
    participant Registry as Executor Registry
    participant Validator as Wiring Validator
    participant Config as actions.yaml

    Startup->>PluginMgr: loadWorkspacePlugins()
    PluginMgr->>Registry: register alert-skill-executor
    PluginMgr->>Registry: register ceremony-skill-executor
    PluginMgr->>Registry: register pr-remediator-skill-executor
    PluginMgr-->>Startup: plugins loaded

    Startup->>Validator: validateActionExecutors(throwOnUnwired)
    Validator->>Config: scan actions.yaml for all skills
    Validator->>Registry: check each skill has executor
    alt All executors wired
        Validator-->>Startup: validation passed
    else Missing executors (strict=1)
        Validator-->>Startup: throw error, startup fails
    else Missing executors (strict=0)
        Validator-->>Startup: log warnings, continue
    end

    Startup->>Startup: listen CONFIG_RELOAD events
    note over Startup: On reload, rerun validator<br/>to catch newly added/removed actions
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 Three executors hop into the registry,
Each skill finds its handler—no mystery!
The validator stands guard at startup's door,
"Show me your wiring, or we're closing this floor!"
GOAP goals now dispatch without a care,
The remediation flywheel spins through the air! 🎯

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: promoting dev to main with A2A routing, chokepoint patterns, and hardening for v0.7.23.
Linked Issues check ✅ Passed The PR addresses #426's requirements: wires missing alert/triage executors (alert-skill-executor, ceremony-skill-executor, pr-remediator-skill-executor plugins), implements fail-loud validation (startup wiring validator runs after loadWorkspacePlugins and on config.reload), and adds actionable error handling for HTML errors and misrouting.
Out of Scope Changes check ✅ Passed All changes are within scope: A2A routing improvements, GOAP executor wiring, validation infrastructure, promotion-PR safeguards, and hitlPolicy support directly address the linked issue objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/plugins/CeremonyPlugin.ts (1)

503-525: ⚠️ Potential issue | 🟠 Major

Hot-reload still misses same-size edits.

This path only reloads when the file size changes. A schedule/target/notify-channel edit with the same byte length never reaches _reloadChangedCeremonies(), so the new enable/disable hot-reload behavior can still be skipped until restart.

Possible fix
-import { existsSync, mkdirSync, readdirSync, copyFileSync } from "node:fs";
+import { existsSync, mkdirSync, readdirSync, copyFileSync, statSync } from "node:fs";
...
-        const stat = Bun.file(filePath).size;
+        const stat = statSync(filePath);
         const existing = this.fileSnapshots.get(filePath);
+        const snapshot = { mtime: stat.mtimeMs, size: stat.size };

         // New or changed file
-        if (!existing || existing.size !== stat) {
-          this.fileSnapshots.set(filePath, { mtime: Date.now(), size: stat });
+        if (!existing || existing.size !== snapshot.size || existing.mtime !== snapshot.mtime) {
+          this.fileSnapshots.set(filePath, snapshot);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/CeremonyPlugin.ts` around lines 503 - 525, The current hot-reload
only compares file size (stat) so same-size edits are ignored; change the
change-detection in the watcher to compare a file modification timestamp as well
(e.g., use Bun.file(filePath).mtime or stat.mtimeMs) and store that alongside
size in this.fileSnapshots; when deciding to reload, treat the file as changed
if either size or mtime differs, update the snapshot entry ({ mtime, size }) and
then run the same new-file vs changed-file logic (loader.loadGlobal(),
registerCeremony(), or _reloadChangedCeremonies()) so edits that preserve byte
length still trigger reloads.
🧹 Nitpick comments (4)
src/pr-remediator.test.ts (1)

1007-1142: Add one bus-level hitlPolicy test for the real merge-ready flow.

These assertions only cover _extractHitlPolicy() and _emitHitlApproval() in isolation. They won't catch regressions in the production path (world.state.updated_selfDispatchRemediation()_handleMergeReady()), which is where the new policy currently gets dropped.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pr-remediator.test.ts` around lines 1007 - 1142, The test suite lacks an
integration-level test that exercises the real production path so hitlPolicy
isn't dropped between world.state.updated → _selfDispatchRemediation() →
_handleMergeReady(); add a new test that installs PrRemediatorPlugin on an
InMemoryEventBus, captures "hitl.request.pr.merge.#", emits the real merge-ready
trigger (the same message format used by
world.state.updated/pr.remediate.merge_ready) with payload.meta.hitlPolicy set
(e.g., { ttlMs: 1_800_000, onTimeout: "approve" }), waits/flushes microtasks,
and asserts the captured HITLRequest contains the same ttlMs and onTimeout
values to ensure _extractHitlPolicy and the production flow
(_selfDispatchRemediation, _handleMergeReady, _emitHitlApproval) propagate the
policy end-to-end.
src/plugins/agent-fleet-health-plugin.ts (1)

327-334: Consider caching the known-agent set for high-outcome-volume scenarios.

_isRegisteredAgent() calls list() and iterates on every outcome. While acceptable for current scale, if outcome volume grows significantly, consider caching the known-agent names and invalidating on registry changes.

♻️ Optional: Cache known agents
+  private cachedKnownAgents: Set<string> | null = null;
+
   private _isRegisteredAgent(actor: string): boolean {
     if (!this.executorRegistry) return true;
-    const known = this.executorRegistry
-      .list()
-      .map(r => r.agentName)
-      .filter((n): n is string => typeof n === "string" && n.length > 0);
-    return known.includes(actor);
+    if (!this.cachedKnownAgents) {
+      this.cachedKnownAgents = new Set(
+        this.executorRegistry
+          .list()
+          .map(r => r.agentName)
+          .filter((n): n is string => typeof n === "string" && n.length > 0)
+      );
+    }
+    return this.cachedKnownAgents.has(actor);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/agent-fleet-health-plugin.ts` around lines 327 - 334, The
_isRegisteredAgent method repeatedly calls this.executorRegistry.list() and
builds an array per outcome; change it to maintain a cached Set<string> of known
agent names (e.g., this._knownAgents) and have _isRegisteredAgent check that Set
(fall back to calling list() on first access). Update the cache whenever the
registry changes by listening for registry change events or by adding a small
helper method (e.g., refreshKnownAgents) that calls
this.executorRegistry.list(), rebuilds the Set, and is invoked on registry
update points; ensure any null/undefined checks for this.executorRegistry remain
and the cache is invalidated or refreshed when executors are added/removed.
src/planner/__tests__/end-to-end-loop.test.ts (1)

142-152: Cooldown test should include a post-outcome re-violation to isolate dispatcher behavior.

Right now, burst suppression can be influenced by planner inFlightGoals. Add one more violation after the first dispatch/outcome settles to prove meta.cooldownMs alone blocks re-dispatch.

Proposed test hardening
@@
-    // Spam-publish 100 violations of the same goal.
+    // Spam-publish 100 violations of the same goal.
     for (let i = 0; i < 100; i++) {
       bus.publish("world.goal.violated", makeViolation("fleet.no_skill_orphaned", `corr-${i}`));
     }
     await new Promise((r) => setTimeout(r, 50));
 
+    // Re-violate after the first dispatch/outcome cycle settles.
+    // This isolates ActionDispatcher cooldown from planner in-flight suppression.
+    bus.publish("world.goal.violated", makeViolation("fleet.no_skill_orphaned", "corr-after-settle"));
+    await new Promise((r) => setTimeout(r, 20));
+
     // Exactly 1 dispatch reaches the executor; the cooldown drops the rest.
     // The planner's inFlightGoals + ActionDispatcher's cooldown both contribute,
     // but the cooldown is what holds after the outcome clears inFlightGoals.
     expect(skillRequests).toHaveLength(1);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/planner/__tests__/end-to-end-loop.test.ts` around lines 142 - 152, The
test should explicitly re-publish one more violation after the first dispatch
and its outcome have settled to ensure suppression is due to ActionDispatcher's
meta.cooldownMs rather than the planner's inFlightGoals; modify the end-to-end
test around the loop that publishes 100 violations (using bus.publish(...,
makeViolation(...))) to wait for the first dispatch/outcome to complete (i.e.,
ensure skillRequests has the single request and any outcome handlers have run)
and then call bus.publish once more with a new correlation id, and finally
assert that skillRequests remains length 1, referencing the existing symbols
bus.publish, makeViolation, skillRequests, inFlightGoals, and the dispatcher
meta.cooldownMs/ActionDispatcher to locate the right spot.
src/api/agent-card.ts (1)

51-53: Harden public-base normalization before composing the card URL.

At Line 52, trimming only one trailing slash can still leave malformed output for values with whitespace or multiple trailing slashes.

Suggested tweak
-  const publicBase = (process.env.WORKSTACEAN_PUBLIC_BASE_URL ?? "").replace(/\/$/, "");
+  const publicBase = (process.env.WORKSTACEAN_PUBLIC_BASE_URL ?? "")
+    .trim()
+    .replace(/\/+$/, "");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/agent-card.ts` around lines 51 - 53, The resolveA2aUrl function
currently only strips a single trailing slash from the env var (publicBase) and
doesn't trim whitespace, so normalize the value more robustly by trimming
surrounding whitespace and removing any number of trailing slashes before
composing the URL; update the publicBase assignment in resolveA2aUrl to use
.trim() and .replace(/\/+$/,"") on process.env.WORKSTACEAN_PUBLIC_BASE_URL (keep
the existing empty-string fallback) and preserve the existing early-return
behavior if publicBase is empty, then return `${publicBase}/a2a`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/plugins/pr-remediator.ts`:
- Around line 1227-1239: The ttlMs extracted in _extractHitlPolicy can be an
extremely large finite number and later cause new Date(Date.now() + ttlMs) to
throw RangeError; fix by defining a MAX_DATE_MS constant (e.g. the max safe
epoch in ms) and cap ttlMs during extraction in _extractHitlPolicy (set
out.ttlMs = Math.min(p.ttlMs, MAX_DATE_MS - Date.now()) while ensuring
non-negative), and also re-validate/cap ttlMs at the consumption point before
calling new Date(...) to guard again against overflow; reference the
_extractHitlPolicy method and the place where ttlMs is used to compute the
deadline so both locations apply the same MAX_DATE_MS cap.

In `@src/api/a2a-server.ts`:
- Around line 114-127: The current logic unconditionally sets targets = ["ava"]
when metadata.targets is empty, which overrides routing based on a provided
skillHint; change the branch in the handler (the explicitTargets / targets
logic) to only set targets = ["ava"] when there are no explicitTargets AND
metadata.skillHint (or equivalent routing hint field) is falsy — if a skillHint
exists leave targets undefined so skill-based routing can proceed; apply the
same fix to the other occurrence around the similar targets defaulting (lines
~243-246) to ensure we don't hard-pin Ava when a routing hint is present.
- Around line 49-74: The current looksLikeHtmlError and sanitizeHtmlError are
too permissive and leak body content; change looksLikeHtmlError to detect
explicit HTTP-error signatures only (case-insensitive exact phrases like "404
Not Found", "500 Internal Server Error", common server banners such as "nginx/",
"apache/", "cloudflare", and regexes matching "<title>\\d{3}" or "cannot POST /"
with word boundaries) rather than any occurrence of "404 not found" or starting
with "<!doctype"/"<html>". Update sanitizeHtmlError to never return upstream
body text to clients — return a fully generic message like "Downstream agent
returned an HTTP error; see server logs for details" and write the full raw
payload to the server log (use the existing logger used elsewhere in this file)
for debugging. Apply the same tightened logic where these helpers are reused
(the other occurrence referenced in the review).

In `@src/planner/__tests__/validate-action-executors.test.ts`:
- Around line 148-156: The test is mocking the bus instead of using the real
in-memory bus, so replace the fake bus object used in the
validateActionExecutors(actions, executors, { bus: bus as never }) call with the
actual in-memory bus instance used by your codebase (the same Bus implementation
exercised by other tests) so publish/subscribe go through the real transport;
locate the mocked symbols (published array, bus.subscribe, bus.unsubscribe,
bus.publish, BusMessage) and remove the mock(...) wrappers, instantiate the real
in-memory bus, pass it into validateActionExecutors, and assert published
messages via the real bus API to keep the test aligned with other *.test.ts
files and avoid mocking the transport.

In `@src/plugins/__tests__/alert-skill-executor-plugin.test.ts`:
- Around line 11-29: The test defines a local makeBus stub
(functions/published/subscribe/publish/topics/unsubscribe) which duplicates
event-bus behavior; replace this local makeBus with the project's shared
in-memory bus used by other tests (import the shared in-memory bus factory or
singleton and use it instead of makeBus) so the test uses the canonical bus
implementation and complies with the no-mocks in-memory-bus rule; update
references to published, subscribe, publish, topics and unsubscribe to use the
imported bus API.

In `@src/plugins/__tests__/ceremony-skill-executor-plugin.test.ts`:
- Around line 11-29: Replace the hand-rolled makeBus stub with the shared
InMemoryEventBus used across plugin tests: import InMemoryEventBus and
instantiate it in place of makeBus(), then update references that relied on the
local published array or custom methods to use InMemoryEventBus's
publish/subscribe/unsubscribe and any provided inspection helpers (e.g.,
getPublished or events history) so tests exercise the real in-memory
publish/subscribe contract; remove the makeBus function and adapt assertions to
the InMemoryEventBus API in the ceremony-skill-executor-plugin test.

In `@src/plugins/__tests__/pr-remediator-skill-executor-plugin.test.ts`:
- Around line 13-30: Replace the bespoke makeBus() test stub with the shared
InMemoryEventBus: import or require InMemoryEventBus in the test file and
instantiate it instead of calling makeBus(), then use the InMemoryEventBus
instance's subscribe, unsubscribe, publish and topics APIs (and its typed
BusMessage usage) so tests exercise the real in-memory semantics; remove the
makeBus() function and update any references in
pr-remediator-skill-executor-plugin.test.ts to call the InMemoryEventBus
instance methods (e.g., subscribe/unsubscribe/publish/topics) so the test uses
the canonical event-bus behavior.
- Around line 179-204: The test currently only checks that the topic
"pr.remediate.update_branch" exists and that
ExecutorRegistry.resolve("action.pr_update_branch") returns an executor, but it
never verifies that PrRemediatorPlugin actually received the dispatched message;
update the test to observe the remediator handling by subscribing or spying on
the bus for the remediator topic before calling executor.execute (e.g., use
InMemoryEventBus.subscribe("pr.remediate.update_branch", ...) or capture
bus.publish calls) and assert that the subscriber was invoked or that an
expected remediator side-effect occurred after executor.execute completes;
reference the existing InMemoryEventBus, PrRemediatorPlugin.install/uninstall,
ExecutorRegistry.resolve("action.pr_update_branch"), and executor.execute call
to locate where to add the observable assertion.

---

Outside diff comments:
In `@src/plugins/CeremonyPlugin.ts`:
- Around line 503-525: The current hot-reload only compares file size (stat) so
same-size edits are ignored; change the change-detection in the watcher to
compare a file modification timestamp as well (e.g., use
Bun.file(filePath).mtime or stat.mtimeMs) and store that alongside size in
this.fileSnapshots; when deciding to reload, treat the file as changed if either
size or mtime differs, update the snapshot entry ({ mtime, size }) and then run
the same new-file vs changed-file logic (loader.loadGlobal(),
registerCeremony(), or _reloadChangedCeremonies()) so edits that preserve byte
length still trigger reloads.

---

Nitpick comments:
In `@src/api/agent-card.ts`:
- Around line 51-53: The resolveA2aUrl function currently only strips a single
trailing slash from the env var (publicBase) and doesn't trim whitespace, so
normalize the value more robustly by trimming surrounding whitespace and
removing any number of trailing slashes before composing the URL; update the
publicBase assignment in resolveA2aUrl to use .trim() and .replace(/\/+$/,"") on
process.env.WORKSTACEAN_PUBLIC_BASE_URL (keep the existing empty-string
fallback) and preserve the existing early-return behavior if publicBase is
empty, then return `${publicBase}/a2a`.

In `@src/planner/__tests__/end-to-end-loop.test.ts`:
- Around line 142-152: The test should explicitly re-publish one more violation
after the first dispatch and its outcome have settled to ensure suppression is
due to ActionDispatcher's meta.cooldownMs rather than the planner's
inFlightGoals; modify the end-to-end test around the loop that publishes 100
violations (using bus.publish(..., makeViolation(...))) to wait for the first
dispatch/outcome to complete (i.e., ensure skillRequests has the single request
and any outcome handlers have run) and then call bus.publish once more with a
new correlation id, and finally assert that skillRequests remains length 1,
referencing the existing symbols bus.publish, makeViolation, skillRequests,
inFlightGoals, and the dispatcher meta.cooldownMs/ActionDispatcher to locate the
right spot.

In `@src/plugins/agent-fleet-health-plugin.ts`:
- Around line 327-334: The _isRegisteredAgent method repeatedly calls
this.executorRegistry.list() and builds an array per outcome; change it to
maintain a cached Set<string> of known agent names (e.g., this._knownAgents) and
have _isRegisteredAgent check that Set (fall back to calling list() on first
access). Update the cache whenever the registry changes by listening for
registry change events or by adding a small helper method (e.g.,
refreshKnownAgents) that calls this.executorRegistry.list(), rebuilds the Set,
and is invoked on registry update points; ensure any null/undefined checks for
this.executorRegistry remain and the cache is invalidated or refreshed when
executors are added/removed.

In `@src/pr-remediator.test.ts`:
- Around line 1007-1142: The test suite lacks an integration-level test that
exercises the real production path so hitlPolicy isn't dropped between
world.state.updated → _selfDispatchRemediation() → _handleMergeReady(); add a
new test that installs PrRemediatorPlugin on an InMemoryEventBus, captures
"hitl.request.pr.merge.#", emits the real merge-ready trigger (the same message
format used by world.state.updated/pr.remediate.merge_ready) with
payload.meta.hitlPolicy set (e.g., { ttlMs: 1_800_000, onTimeout: "approve" }),
waits/flushes microtasks, and asserts the captured HITLRequest contains the same
ttlMs and onTimeout values to ensure _extractHitlPolicy and the production flow
(_selfDispatchRemediation, _handleMergeReady, _emitHitlApproval) propagate the
policy end-to-end.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 12091d6b-7198-4c3c-8cf1-0cc8e010a7b8

📥 Commits

Reviewing files that changed from the base of the PR and between 14e2045 and 4001cf7.

📒 Files selected for processing (48)
  • .claude/commands/rollcall.md
  • .env.dist
  • README.md
  • __tests__/goal_evaluator_plugin.test.ts
  • docs/reference/ceremony-plugin.md
  • docs/reference/env-vars.md
  • docs/reference/http-api.md
  • lib/plugins/pr-remediator.ts
  • src/api/__tests__/a2a-server.test.ts
  • src/api/__tests__/agent-card.test.ts
  • src/api/a2a-server.ts
  • src/api/agent-card.ts
  • src/api/github.ts
  • src/config/env.ts
  • src/index.ts
  • src/loaders/ceremonyYamlLoader.ts
  • src/planner/__tests__/end-to-end-loop.test.ts
  • src/planner/__tests__/validate-action-executors.test.ts
  • src/planner/types/action.ts
  • src/planner/validate-action-executors.ts
  • src/plugins/CeremonyPlugin.ts
  • src/plugins/__tests__/CeremonyPlugin.test.ts
  • src/plugins/__tests__/alert-skill-executor-plugin.test.ts
  • src/plugins/__tests__/ceremony-skill-executor-plugin.test.ts
  • src/plugins/__tests__/pr-remediator-skill-executor-plugin.test.ts
  • src/plugins/action-dispatcher-plugin.test.ts
  • src/plugins/action-dispatcher-plugin.ts
  • src/plugins/agent-fleet-health-plugin.test.ts
  • src/plugins/agent-fleet-health-plugin.ts
  • src/plugins/alert-skill-executor-plugin.ts
  • src/plugins/ceremony-skill-executor-plugin.ts
  • src/plugins/goal_evaluator_plugin.ts
  • src/plugins/pr-remediator-skill-executor-plugin.ts
  • src/pr-remediator.test.ts
  • src/schemas/yaml-schemas.ts
  • src/telemetry/telemetry-service.ts
  • src/world/extensions/CeremonyStateExtension.ts
  • src/world/extensions/__tests__/CeremonyStateExtension.test.ts
  • workspace/actions.yaml
  • workspace/agents.yaml
  • workspace/agents/ava.yaml
  • workspace/ceremonies/agent-health.yaml
  • workspace/ceremonies/board.cleanup.yaml
  • workspace/ceremonies/board.health.yaml
  • workspace/ceremonies/board.retro.yaml
  • workspace/ceremonies/daily-standup.yaml
  • workspace/ceremonies/health-check.yaml
  • workspace/ceremonies/service-health.yaml

Comment on lines +936 to +939
// Extract hitlPolicy forwarded by PrRemediatorSkillExecutorPlugin so the
// fallback HITL request honours action.pr_merge_ready.meta.hitlPolicy
// (ttlMs: 30min, onTimeout: approve).
const hitlPolicy = this._extractHitlPolicy(msg);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

hitlPolicy is skipped on the path this plugin uses by default.

_extractHitlPolicy(msg) only works for a pr.remediate.merge_ready payload, but the normal autonomous path calls _handleMergeReady() from world.state.updated via _selfDispatchRemediation(). That message has no payload.meta.hitlPolicy, so the new forwarding never reaches _emitHitlApproval() unless some external publisher triggers pr.remediate.merge_ready directly.

Also applies to: 969-969

Comment on lines +1227 to +1239
private _extractHitlPolicy(msg: BusMessage): { ttlMs?: number; onTimeout?: "approve" | "reject" | "escalate" } | undefined {
const payload = msg.payload as { meta?: Record<string, unknown> } | undefined;
const meta = payload?.meta;
if (!meta || typeof meta !== "object") return undefined;
const policy = (meta as Record<string, unknown>).hitlPolicy;
if (!policy || typeof policy !== "object") return undefined;
const p = policy as Record<string, unknown>;
const out: { ttlMs?: number; onTimeout?: "approve" | "reject" | "escalate" } = {};
if (typeof p.ttlMs === "number" && Number.isFinite(p.ttlMs) && p.ttlMs > 0) out.ttlMs = p.ttlMs;
if (p.onTimeout === "approve" || p.onTimeout === "reject" || p.onTimeout === "escalate") {
out.onTimeout = p.onTimeout;
}
return Object.keys(out).length > 0 ? out : undefined;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

node - <<'NODE'
for (const ttl of [1e20, Number.MAX_VALUE]) {
  try {
    console.log("ttl=", ttl, "expiresAt=", new Date(Date.now() + ttl).toISOString());
  } catch (err) {
    console.error("ttl=", ttl, "->", String(err));
  }
}
NODE

rg -n "hitlPolicy|ttlMs" workspace src lib

Repository: protoLabsAI/protoWorkstacean

Length of output: 6609


🏁 Script executed:

sed -n '1220,1280p' lib/plugins/pr-remediator.ts | cat -n

Repository: protoLabsAI/protoWorkstacean

Length of output: 3373


🏁 Script executed:

sed -n '1091,1145p' src/pr-remediator.test.ts | cat -n

Repository: protoLabsAI/protoWorkstacean

Length of output: 2047


Cap ttlMs to a safe maximum to prevent RangeError in Date constructor.

Number.isFinite() accepts very large finite values (e.g., 1e20) that overflow JavaScript's maximum safe timestamp (~8.64e15). This causes new Date(Date.now() + ttlMs).toISOString() to throw RangeError, breaking the merge-failure fallback. Add validation to enforce ttlMs <= MAX_DATE_MS - Date.now() at both validation (line 1235) and consumption (line 1256) points.

Suggested fix
+const MAX_DATE_MS = 8.64e15;
+
 private _extractHitlPolicy(msg: BusMessage): { ttlMs?: number; onTimeout?: "approve" | "reject" | "escalate" } | undefined {
   const payload = msg.payload as { meta?: Record<string, unknown> } | undefined;
   const meta = payload?.meta;
   if (!meta || typeof meta !== "object") return undefined;
   const policy = (meta as Record<string, unknown>).hitlPolicy;
   if (!policy || typeof policy !== "object") return undefined;
   const p = policy as Record<string, unknown>;
   const out: { ttlMs?: number; onTimeout?: "approve" | "reject" | "escalate" } = {};
-  if (typeof p.ttlMs === "number" && Number.isFinite(p.ttlMs) && p.ttlMs > 0) out.ttlMs = p.ttlMs;
+  if (typeof p.ttlMs === "number" && Number.isFinite(p.ttlMs) && p.ttlMs > 0) {
+    out.ttlMs = Math.min(p.ttlMs, Math.max(1, MAX_DATE_MS - Date.now()));
+  }
   if (p.onTimeout === "approve" || p.onTimeout === "reject" || p.onTimeout === "escalate") {
     out.onTimeout = p.onTimeout;
   }
   return Object.keys(out).length > 0 ? out : undefined;
 }
@@
-  const ttlMs = hitlPolicy?.ttlMs ?? 30 * 60 * 1000;
+  const ttlMs = Math.min(hitlPolicy?.ttlMs ?? 30 * 60 * 1000, Math.max(1, MAX_DATE_MS - Date.now()));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/plugins/pr-remediator.ts` around lines 1227 - 1239, The ttlMs extracted
in _extractHitlPolicy can be an extremely large finite number and later cause
new Date(Date.now() + ttlMs) to throw RangeError; fix by defining a MAX_DATE_MS
constant (e.g. the max safe epoch in ms) and cap ttlMs during extraction in
_extractHitlPolicy (set out.ttlMs = Math.min(p.ttlMs, MAX_DATE_MS - Date.now())
while ensuring non-negative), and also re-validate/cap ttlMs at the consumption
point before calling new Date(...) to guard again against overflow; reference
the _extractHitlPolicy method and the place where ttlMs is used to compute the
deadline so both locations apply the same MAX_DATE_MS cap.

Comment thread src/api/a2a-server.ts
Comment on lines +49 to +74
function looksLikeHtmlError(text: string): boolean {
if (!text) return false;
const head = text.slice(0, 256).toLowerCase();
return (
head.startsWith("<!doctype")
|| head.startsWith("<html")
|| head.includes("cannot post /")
|| head.includes("cannot get /")
|| head.includes("404 not found")
);
}

function sanitizeHtmlError(raw: string): string {
// Strip tags + `<!DOCTYPE ...>` so the debug hint itself contains no markup
// — the whole point is that the caller shouldn't see HTML in their reply.
const hint = raw
.replace(/<!DOCTYPE[^>]*>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 200);
return (
"Downstream agent returned an HTTP error (possibly a card misconfiguration). "
+ "See workstacean logs for details. "
+ `[debug hint: ${hint}]`
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The HTML-error heuristic is too broad, and the sanitized reply still leaks body text.

Any successful reply that legitimately starts with <!DOCTYPE/<html> or discusses 404 not found will be converted into a failed task here. On top of that, sanitizeHtmlError() echoes the first 200 chars of the upstream page back to the caller, which weakens the stated sanitization goal. Tighten detection to actual HTTP-error signatures and return a fully generic client-facing message while keeping the raw payload only in logs.

Possible fix
 function looksLikeHtmlError(text: string): boolean {
   if (!text) return false;
   const head = text.slice(0, 256).toLowerCase();
-  return (
-    head.startsWith("<!doctype")
-    || head.startsWith("<html")
-    || head.includes("cannot post /")
-    || head.includes("cannot get /")
-    || head.includes("404 not found")
-  );
+  const looksLikeMarkup = head.startsWith("<!doctype") || head.startsWith("<html");
+  const hasHttpErrorSignature =
+    head.includes("cannot post /")
+    || head.includes("cannot get /")
+    || head.includes("404 not found")
+    || head.includes("500 internal server error");
+  return looksLikeMarkup && hasHttpErrorSignature;
 }
 
-function sanitizeHtmlError(raw: string): string {
-  const hint = raw
-    .replace(/<!DOCTYPE[^>]*>/gi, "")
-    .replace(/<[^>]+>/g, " ")
-    .replace(/\s+/g, " ")
-    .trim()
-    .slice(0, 200);
-  return (
-    "Downstream agent returned an HTTP error (possibly a card misconfiguration). "
-    + "See workstacean logs for details. "
-    + `[debug hint: ${hint}]`
-  );
+function sanitizeHtmlError(_raw: string): string {
+  return "Downstream agent returned an HTTP error. See workstacean logs for details.";
 }

Also applies to: 163-175

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/a2a-server.ts` around lines 49 - 74, The current looksLikeHtmlError
and sanitizeHtmlError are too permissive and leak body content; change
looksLikeHtmlError to detect explicit HTTP-error signatures only
(case-insensitive exact phrases like "404 Not Found", "500 Internal Server
Error", common server banners such as "nginx/", "apache/", "cloudflare", and
regexes matching "<title>\\d{3}" or "cannot POST /" with word boundaries) rather
than any occurrence of "404 not found" or starting with "<!doctype"/"<html>".
Update sanitizeHtmlError to never return upstream body text to clients — return
a fully generic message like "Downstream agent returned an HTTP error; see
server logs for details" and write the full raw payload to the server log (use
the existing logger used elsewhere in this file) for debugging. Apply the same
tightened logic where these helpers are reused (the other occurrence referenced
in the review).

Comment thread src/api/a2a-server.ts
Comment on lines +114 to +127
const explicitTargets = Array.isArray(metadata.targets)
? (metadata.targets as unknown[]).filter((v): v is string => typeof v === "string")
: [];
// This endpoint (ava.proto-labs.ai/a2a) defaults to Ava when no target is
// specified. The orchestrator agent card aggregates the full fleet's
// skills, but the routing default must be Ava — she's the helm. Callers
// route elsewhere by passing explicit `metadata.targets`.
let targets: string[];
if (explicitTargets.length === 0) {
targets = ["ava"];
console.log("[a2a-server] no target specified, defaulting to [ava] for message/send");
} else {
targets = explicitTargets;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Only default to Ava when there is no routing hint.

This now injects targets: ["ava"] for every target-less request, including calls that already supplied a skillHint. Since the shared agent card advertises fleet-wide skills, that hard-pin bypasses normal skill-based routing and will fail non-Ava skills like protomaker/quinn handlers. Leave targets unset when a skill hint is present, and only apply the Ava default when neither target nor routing hint was provided.

Possible fix
-    let targets: string[];
-    if (explicitTargets.length === 0) {
-      targets = ["ava"];
-      console.log("[a2a-server] no target specified, defaulting to [ava] for message/send");
-    } else {
-      targets = explicitTargets;
-    }
+    const hasExplicitSkillHint =
+      (typeof metadata.skillHint === "string" && metadata.skillHint.length > 0)
+      || (typeof metadata.skill === "string" && metadata.skill.length > 0);
+    const targets =
+      explicitTargets.length > 0
+        ? explicitTargets
+        : hasExplicitSkillHint
+          ? undefined
+          : ["ava"];
+    if (!targets) {
+      // let SkillDispatcherPlugin resolve by skill
+    } else if (explicitTargets.length === 0) {
+      console.log("[a2a-server] no target specified, defaulting to [ava] for message/send");
+    }
...
-        targets,
+        ...(targets ? { targets } : {}),

Also applies to: 243-246

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/a2a-server.ts` around lines 114 - 127, The current logic
unconditionally sets targets = ["ava"] when metadata.targets is empty, which
overrides routing based on a provided skillHint; change the branch in the
handler (the explicitTargets / targets logic) to only set targets = ["ava"] when
there are no explicitTargets AND metadata.skillHint (or equivalent routing hint
field) is falsy — if a skillHint exists leave targets undefined so skill-based
routing can proceed; apply the same fix to the other occurrence around the
similar targets defaulting (lines ~243-246) to ensure we don't hard-pin Ava when
a routing hint is present.

Comment on lines +148 to +156
const published: BusMessage[] = [];
const bus = {
subscribe: mock(() => "sub-id"),
unsubscribe: mock(() => {}),
publish: mock((_topic: string, msg: BusMessage) => { published.push(msg); }),
topics: () => [],
};

validateActionExecutors(actions, executors, { bus: bus as never });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Don't mock the bus in this test.

validateActionExecutors() publishes a normal bus message, so this coverage should exercise the real in-memory bus path instead of a mock() wrapper. That keeps the test aligned with the rest of the suite and avoids validating a fake transport.

As per coding guidelines, **/*.test.{ts,tsx}: Write unit tests with bun test against in-memory bus with no mocks and no LLM calls.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/planner/__tests__/validate-action-executors.test.ts` around lines 148 -
156, The test is mocking the bus instead of using the real in-memory bus, so
replace the fake bus object used in the validateActionExecutors(actions,
executors, { bus: bus as never }) call with the actual in-memory bus instance
used by your codebase (the same Bus implementation exercised by other tests) so
publish/subscribe go through the real transport; locate the mocked symbols
(published array, bus.subscribe, bus.unsubscribe, bus.publish, BusMessage) and
remove the mock(...) wrappers, instantiate the real in-memory bus, pass it into
validateActionExecutors, and assert published messages via the real bus API to
keep the test aligned with other *.test.ts files and avoid mocking the
transport.

Comment on lines +11 to +29
function makeBus() {
const subs = new Map<string, Array<(msg: BusMessage) => void>>();
const published: BusMessage[] = [];
return {
published,
subscribe(topic: string, _name: string, handler: (msg: BusMessage) => void) {
if (!subs.has(topic)) subs.set(topic, []);
subs.get(topic)!.push(handler);
return `sub-${topic}-${Math.random()}`;
},
unsubscribe(_id: string) {},
publish(topic: string, msg: BusMessage) {
published.push(msg);
const handlers = subs.get(topic) ?? [];
for (const h of handlers) h(msg);
},
topics() { return []; },
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use the shared in-memory bus in this test file.

This stub is close enough to satisfy the current assertions, but it is still a separate event-bus implementation with its own behavior. That weakens the value of these tests and conflicts with the repo’s test rule.

As per coding guidelines, **/*.test.{ts,tsx}: Write unit tests with bun test against in-memory bus with no mocks and no LLM calls.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/__tests__/alert-skill-executor-plugin.test.ts` around lines 11 -
29, The test defines a local makeBus stub
(functions/published/subscribe/publish/topics/unsubscribe) which duplicates
event-bus behavior; replace this local makeBus with the project's shared
in-memory bus used by other tests (import the shared in-memory bus factory or
singleton and use it instead of makeBus) so the test uses the canonical bus
implementation and complies with the no-mocks in-memory-bus rule; update
references to published, subscribe, publish, topics and unsubscribe to use the
imported bus API.

Comment on lines +11 to +29
function makeBus() {
const subs = new Map<string, Array<(msg: BusMessage) => void>>();
const published: BusMessage[] = [];
return {
published,
subscribe(topic: string, _name: string, handler: (msg: BusMessage) => void) {
if (!subs.has(topic)) subs.set(topic, []);
subs.get(topic)!.push(handler);
return `sub-${topic}-${Math.random()}`;
},
unsubscribe(_id: string) {},
publish(topic: string, msg: BusMessage) {
published.push(msg);
const handlers = subs.get(topic) ?? [];
for (const h of handlers) h(msg);
},
topics() { return []; },
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use InMemoryEventBus for these tests instead of a local stub.

A hand-rolled bus makes this suite less representative than the rest of the plugin tests and can hide contract drift in publish/subscribe behavior.

As per coding guidelines, **/*.test.{ts,tsx}: Write unit tests with bun test against in-memory bus with no mocks and no LLM calls.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/__tests__/ceremony-skill-executor-plugin.test.ts` around lines 11
- 29, Replace the hand-rolled makeBus stub with the shared InMemoryEventBus used
across plugin tests: import InMemoryEventBus and instantiate it in place of
makeBus(), then update references that relied on the local published array or
custom methods to use InMemoryEventBus's publish/subscribe/unsubscribe and any
provided inspection helpers (e.g., getPublished or events history) so tests
exercise the real in-memory publish/subscribe contract; remove the makeBus
function and adapt assertions to the InMemoryEventBus API in the
ceremony-skill-executor-plugin test.

Comment on lines +13 to +30
function makeBus() {
const subs = new Map<string, Array<(msg: BusMessage) => void>>();
const published: BusMessage[] = [];
return {
published,
subscribe(topic: string, _name: string, handler: (msg: BusMessage) => void) {
if (!subs.has(topic)) subs.set(topic, []);
subs.get(topic)!.push(handler);
return `sub-${topic}-${Math.random()}`;
},
unsubscribe(_id: string) {},
publish(topic: string, msg: BusMessage) {
published.push(msg);
const handlers = subs.get(topic) ?? [];
for (const h of handlers) h(msg);
},
topics() { return []; },
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use InMemoryEventBus here instead of a bespoke stub.

This helper skips the real bus semantics the production plugins rely on, so these unit tests can stay green while the actual event-bus contract drifts. Replacing makeBus() with the shared in-memory bus would make the coverage match the rest of the suite.

As per coding guidelines, **/*.test.{ts,tsx}: Write unit tests with bun test against in-memory bus with no mocks and no LLM calls.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/__tests__/pr-remediator-skill-executor-plugin.test.ts` around
lines 13 - 30, Replace the bespoke makeBus() test stub with the shared
InMemoryEventBus: import or require InMemoryEventBus in the test file and
instantiate it instead of calling makeBus(), then use the InMemoryEventBus
instance's subscribe, unsubscribe, publish and topics APIs (and its typed
BusMessage usage) so tests exercise the real in-memory semantics; remove the
makeBus() function and update any references in
pr-remediator-skill-executor-plugin.test.ts to call the InMemoryEventBus
instance methods (e.g., subscribe/unsubscribe/publish/topics) so the test uses
the canonical event-bus behavior.

Comment on lines +179 to +204
it("dispatching action.pr_update_branch triggers PrRemediatorPlugin's handler", async () => {
const bus = new InMemoryEventBus();
const remediator = new PrRemediatorPlugin();
remediator.install(bus);

const reg = new ExecutorRegistry();
const skillExec = new PrRemediatorSkillExecutorPlugin(reg);
skillExec.install(bus);

// No PR data cached — handler will log "no PRs match" and return cleanly.
// We capture the log topic by spying on the underlying bus subscription
// count: the remediator subscribes to pr.remediate.update_branch in install.
expect(bus.topics().map(t => t.pattern)).toContain("pr.remediate.update_branch");

const executor = reg.resolve("action.pr_update_branch")!;
const result = await executor.execute({
skill: "action.pr_update_branch",
correlationId: "int-1",
replyTopic: "reply.test",
payload: { skill: "action.pr_update_branch" },
});
expect(result.isError).toBe(false);

remediator.uninstall();
skillExec.uninstall();
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

These “integration” tests never prove the remediator handled the message.

Both cases still pass if execute() publishes to a dead topic, because the only extra assertion is that a subscription exists on the bus. Add an observable assertion after dispatch — e.g. subscribe to the remediator topic or assert a remediator side effect — so the test actually covers the executor → bus → handler path it claims to verify.

Also applies to: 206-228

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/__tests__/pr-remediator-skill-executor-plugin.test.ts` around
lines 179 - 204, The test currently only checks that the topic
"pr.remediate.update_branch" exists and that
ExecutorRegistry.resolve("action.pr_update_branch") returns an executor, but it
never verifies that PrRemediatorPlugin actually received the dispatched message;
update the test to observe the remediator handling by subscribing or spying on
the bus for the remediator topic before calling executor.execute (e.g., use
InMemoryEventBus.subscribe("pr.remediate.update_branch", ...) or capture
bus.publish calls) and assert that the subscriber was invoked or that an
expected remediator side-effect occurred after executor.execute completes;
reference the existing InMemoryEventBus, PrRemediatorPlugin.install/uninstall,
ExecutorRegistry.resolve("action.pr_update_branch"), and executor.execute call
to locate where to add the observable assertion.

Comment on lines +145 to +176
/**
* Returns true and records the timestamp if the action may proceed.
* Returns false (and logs a fail-fast diagnostic) if the action is still
* within its meta.cooldownMs window — caller must drop the dispatch.
* Actions without meta.cooldownMs always pass.
*/
private _admitOrCooldown(action: import("../planner/types/action.ts").Action): boolean {
const cooldownMs = action.meta.cooldownMs;
if (typeof cooldownMs !== "number" || cooldownMs <= 0) {
// No cooldown configured — admit immediately, no bookkeeping.
return true;
}

const now = Date.now();
const last = this.lastDispatchedAt.get(action.id);
if (last !== undefined) {
const ageMs = now - last;
if (ageMs < cooldownMs) {
const remainingMs = cooldownMs - ageMs;
// Fail-fast & loud: operator must see what's being throttled.
console.warn(
`[action-dispatcher] cooldown drop action=${action.id} ` +
`goal=${action.goalId} ageMs=${ageMs} cooldownMs=${cooldownMs} ` +
`remainingMs=${remainingMs}`,
);
this.telemetry?.bump("action", action.id, "cooldown_dropped");
return false;
}
}
this.lastDispatchedAt.set(action.id, now);
return true;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't start the cooldown window before the action is actually admitted.

Line 174 records lastDispatchedAt before the target guard and before tryDispatch(). That means a dispatch dropped as target_unresolved — or one left pending because WIP is full — still consumes the cooldown window, so fixing the registry or freeing capacity does not allow an immediate retry. The timestamp should only be written once the action has actually been accepted for execution.

Also applies to: 231-245

Automaker added 2 commits April 21, 2026 23:18
…s-strategy back-merge

The prior back-merge of main → dev (#468) was force-squashed when CodeRabbit
froze, dropping main as an ancestor of dev. This back-merge re-establishes
that ancestry, but the ort/ours strategy preserved main's pre-#474 validator
block alongside dev's new runWiringValidator() helper — both were present.

Drop the older duplicate so dev's #474 layout stands: validator runs once,
AFTER loadWorkspacePlugins(), and re-runs on config.reload.

Tests: 1059/1059 pass.
…motion-conflicts

chore: back-merge main → dev (fix promotion-conflict topology, real merge commit this time)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/pr-remediator.test.ts (1)

983-1143: Remove duplicated hitlPolicy suite to avoid double-running the same tests.

The block starting at Line 993 duplicates the existing describe("PrRemediatorPlugin — hitlPolicy") suite already present later (Line 1155+). Keep a single suite (preferably this expanded one) and delete the duplicate block to prevent redundant CI work and split maintenance.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pr-remediator.test.ts` around lines 983 - 1143, Delete the duplicated
test suite describe("PrRemediatorPlugin — hitlPolicy") (the earlier block
containing tests referencing privateOf(plugin)._emitHitlApproval and
_extractHitlPolicy) so only one copy remains; keep the expanded/desired version
and remove the other duplicate block that runs the same tests to avoid double
execution in CI.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/pr-remediator.test.ts`:
- Around line 983-1143: Delete the duplicated test suite
describe("PrRemediatorPlugin — hitlPolicy") (the earlier block containing tests
referencing privateOf(plugin)._emitHitlApproval and _extractHitlPolicy) so only
one copy remains; keep the expanded/desired version and remove the other
duplicate block that runs the same tests to avoid double execution in CI.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ea2eb94e-36b6-47d7-97a9-bd0c47490e9a

📥 Commits

Reviewing files that changed from the base of the PR and between 4001cf7 and 705f2b5.

📒 Files selected for processing (1)
  • src/pr-remediator.test.ts

@mabry1985 mabry1985 merged commit ea88505 into main Apr 22, 2026
8 checks passed
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.

fix(skill-dispatcher): 6 GOAP-wired alert/triage skills have no executor — dispatches silently dropped

1 participant