Skip to content

[WIP] Port nanobot to C# with extensible environment support#1

Merged
sytone merged 5 commits into
mainfrom
copilot/port-nanobot-to-csharp
Apr 1, 2026
Merged

[WIP] Port nanobot to C# with extensible environment support#1
sytone merged 5 commits into
mainfrom
copilot/port-nanobot-to-csharp

Conversation

Copilot AI commented Apr 1, 2026

Copy link
Copy Markdown
Contributor
  • Explored codebase and understood current architecture
  • Phase 1: Activity Stream (Core) — Added IActivityStream/ActivityStream to broadcast all system events
    • Created IActivityStream abstraction + IActivitySubscription in Core
    • Created ActivityEvent model (MessageReceived, ResponseSent, DeltaSent, AgentProcessing, AgentCompleted, Error)
    • Created ActivityStream implementation using bounded channels with DropOldest policy
    • Registered ActivityStream in ServiceCollectionExtensions
  • Phase 2: Gateway REST API — Added endpoints for listing sessions, agents, and channels
    • GET /api/sessions — lists all sessions with metadata (key, agent, channel, message count, timestamps)
    • GET /api/sessions/{key} — returns full session history with role/content/timestamp/tool info
    • GET /api/channels — lists channels with name, displayName, running status, streaming support
    • GET /api/agents — lists configured agents with model, temperature, tokens, etc.
  • Phase 3: Enhanced WebSocket — Added "subscribe" mode for monitoring all traffic
    • Modified GatewayWebSocketHandler to support {"type":"subscribe"} message
    • Gateway publishes MessageReceived events when dispatching messages
    • Gateway publishes Error events when processing fails
    • WebSocketChannel publishes ResponseSent and DeltaSent events
    • Activity events are broadcast as {"type":"activity","event":{...}} to subscribed clients
  • Phase 4: Web UI — Created BotNexus.WebUI project with static HTML/CSS/JS
    • index.html — SPA layout with sidebar (sessions, channels, agents, activity monitor) + chat area
    • styles.css — dark theme chat UI with responsive design
    • app.js — WebSocket connection, REST API calls, session browsing, real-time chat, activity feed
    • Gateway serves static files from WebUI's wwwroot via build targets
  • Phase 5: Solution + Tests — Updated solution, added tests, verified build
    • Added BotNexus.WebUI to solution file
    • Added 10 unit tests for ActivityStream (publish, subscribe, broadcast, dispose, concurrency)
    • All 124 tests pass (121 unit + 3 integration), 0 errors
Original prompt

ok, this is a port of nanobot(https://github.com/HKUDS/nanobot) to C# as it has a good clear design, using that as a base approach this will also support the complexity and functionality of open claw but by making the environment extensible. The code base uses the latest version of .net and the c# language specification. It is a AI first project that will leverage agents to help build, validate, maintain and review the code base to make sure it is clean and understandable as well as secure. The core system handles the orchestration of multiple agents with there own isolated work spaces which can run in containers to provide extra layers of isolation and control as to what information and tools they can access. A agent can have multiple sessions across multiple channels allows its SOUL/MEMORY/etc to be the same in all interactions with the agent. All channels and providers are extensions to the core platform and can be added by the user, commands sent to the agent should have a core set and then be extensible, this is the philosophy of the platform. Solid small core and then extensibility via extension libraries in c# or skills. So go through an port nanobot to c# following the structure in that repo translated to c#, follow good SOLID principles, make sure there is very high test coverage and also prepare the repo for agent level test coverage so there can be other tests generate later to eval created agents and the instructions/skills loaded. All the providers and channels covers in the nanobot code should be supported, each one would be and extension libary that can be dynamically loaded by the code if referenced by the main configuration, again this is a composable claw meant for flexibility, transparency and understanding and designed to be used and extended by agents as the primary source of development so you need to have strong testing and validation approaches including the execution of the gateway and testing it handing multiple agents and sessions like openclaw does. The nanobot approach of separate gateways or executions per agent/workspace is something that this implementation will not follow, the gateway should start and from the configuration load up the agents and sessions defined in the config similar to how openclaw does it. Openclaw code and documentation can be found at https://github.com/openclaw/openclaw. You need to keep iterating until this initial port and alignment is done and code coverage is over 90% and you are sure you have aligned to the principles of what I want.

- Full solution structure with 15 projects targeting .NET 10
- BotNexus.Core: abstractions (ILlmProvider, IChannel, ISessionManager, etc.), models, configuration, message bus
- BotNexus.Session: file-backed JSONL session persistence
- BotNexus.Agent: AgentLoop, AgentRunner, ContextBuilder, MemoryStore, SkillsLoader, tools (filesystem, shell, web, message, cron)
- BotNexus.Command: priority-based command router with built-in /help /reset /status
- BotNexus.Channels.Base: BaseChannel abstract class, ChannelManager
- BotNexus.Providers.Base: LlmProviderBase with retry logic, ProviderRegistry
- BotNexus.Providers.OpenAI: full OpenAI SDK integration with streaming
- BotNexus.Providers.Anthropic: Anthropic Messages API via HttpClient
- BotNexus.Channels.Telegram/Discord/Slack: channel implementations
- BotNexus.Cron: Cronos-based cron scheduling as IHostedService
- BotNexus.Heartbeat: heartbeat service as IHostedService
- BotNexus.Gateway: main hosted service wiring everything together
- BotNexus.Api: OpenAI-compatible REST API
- 61 passing tests (58 unit + 3 integration)
- GitHub Actions CI workflow

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: sytone <1399443+sytone@users.noreply.github.com>
- Switch gateway from Worker SDK to Web SDK (enables Kestrel + WebSockets)
- Add WebSocketEnabled / WebSocketPath to GatewayConfig
- Add WebSocketChannel: IChannel that routes agent responses to connected sockets via per-connection Channel<string> response queues
- Add GatewayWebSocketHandler: accepts upgrades on /ws, publishes inbound messages to IMessageBus, streams responses back to client
- Program.cs: WebApplication with Kestrel on GatewayConfig.Host:Port, /health endpoint, /ws branch
- Register WebSocketChannel + GatewayWebSocketHandler in BotNexusServiceExtensions
- Add 12 unit tests for WebSocketChannel

Agent-Logs-Url: https://github.com/sytone/botnexus/sessions/03c18968-8be4-4db7-9bed-7eaf12079ae7

Co-authored-by: sytone <1399443+sytone@users.noreply.github.com>
…hanced WebSocket monitor mode

- Add IActivityStream/ActivityStream to Core for broadcasting system-wide events
- Add ActivityEvent model for message received/response sent/delta/error events
- Add REST API endpoints: GET /api/sessions, /api/sessions/{key}, /api/channels, /api/agents
- Enhance Gateway to publish activity events on message dispatch and errors
- Enhance GatewayWebSocketHandler to support 'subscribe' message type for monitoring all traffic
- Enhance WebSocketChannel to publish outbound activity events
- Create BotNexus.WebUI project with static HTML/CSS/JS chat UI
- Web UI features: session list, agent list, channel status, activity monitor, real-time chat
- Add 10 unit tests for ActivityStream
- All 124 tests pass

Agent-Logs-Url: https://github.com/sytone/botnexus/sessions/a15b4908-b6b1-4463-9d9d-d4817cd0b9bd

Co-authored-by: sytone <1399443+sytone@users.noreply.github.com>

@sytone sytone left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Not overly concerned of initial check-in this is just to get the framework based structure in place for a deeper review and local development environment execution

@sytone sytone marked this pull request as ready for review April 1, 2026 14:54
@sytone sytone merged commit 5f59957 into main Apr 1, 2026
1 check passed
@sytone sytone deleted the copilot/port-nanobot-to-csharp branch April 1, 2026 14:55
sytone pushed a commit that referenced this pull request Apr 3, 2026
**Issue #1: Config write safety**
- ConfigFileManager.SaveConfig now uses JsonNode for surgical updates
- Preserves all fields not in C# model (prevents data loss)
- Deep merge strategy ensures provider-specific fields survive

**Issue #2: Auto-reauth on token expiry**
- CopilotProvider now clears ALL tokens on 401/403 (GitHub OAuth + Copilot access)
- Automatic retry with fresh auth after token exchange failure
- Clear error messages guide user to re-authenticate
- Prevents silent failures when token is missing/expired

**Fixes:**
- Added missing using directive (BotNexus.Core.Bus) to Program.cs
- Fixed null reference warning in CopilotProvider.ChatCoreAsync
- Updated TestActivityStream mock to implement new IActivityStream method

All Copilot provider tests passing (6/6).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
sytone pushed a commit that referenced this pull request Apr 16, 2026
- Add IMediaHandler to additive DI registration branch (was TryAddSingleton)
- Add botnexus-extension.json manifest for Whisper extension discovery
- Copy manifest to output directory via Content item
- Fixes: Nibbler consistency review issues #1 and #2

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
sytone added a commit that referenced this pull request May 7, 2026
…ecisions

Multi-agent coordination session for issue #24 (Tool Timeout Configuration & Auto-Cancel).
Completed Phase 1 delivery with requirements #1, #2, #3, #8 (config wiring, runtime
propagation, structured logging). Follow-ups tracked separately (stuck-turn, user cancel,
auto-cancel, UI health).

Changes:
- Added orchestration logs for all spawned agents (Leela, Hermes, Bender, Nibbler, Coordinator)
- Added session log for issue #24 multi-agent coordination and outcomes
- Merged decision inbox files into decisions.md, deleted inbox files
- Updated agent history files with issue #24 session outcomes
- Created decisions-archive.md placeholder for future archival (decisions.md: 739KB > 20KB threshold)

Orchestration Outcomes:
- Leela: Architecture review approved (minimal, correct, well-bounded)
- Hermes: TDD test contracts locked and verified (3 regression tests passing)
- Bender: Runtime implementation complete (config→descriptor→runtime wiring)
- Nibbler: Consistency review verified (docs aligned, no cross-agent drift)
- Coordinator: PR submitted (#177)

Test Coverage: All 24 issue-related tests passing (9 AgentCore + 15 Gateway)
PR Status: Ready for merge (awaiting CI)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
sytone added a commit that referenced this pull request May 8, 2026
* test(gateway): add issue #24 timeout wiring regressions

Add regression tests for missing tool-timeout configuration plumbing from platform config through in-process runtime setup, and add an AgentCore timeout end-event coverage test.\n\nThese tests intentionally document current gaps for issue #24 while preserving existing timeout recovery assertions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(gateway): wire tool timeout config into runtime (#24)

Flow toolTimeoutSeconds from agent/default config into descriptor metadata and apply it as AgentOptions.ToolTimeout in in-process runtime creation. Add inline agents.defaults fallback for direct PlatformConfig construction and structured logging for invalid timeout metadata.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(config): add ToolTimeoutSeconds to config reference tables (#24)

- Add ToolTimeoutSeconds to AgentDefaults and per-agent property tables
  in docs/configuration.md to match new AgentDefaultsConfig and
  AgentDefinitionConfig properties added by this branch.
- Update design spec status from draft to in-progress since config
  wiring is now implemented (stuck-turn and cancel still pending).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(sessions): update documentation for session store implementations

Co-authored-by: Copilot <copilot@github.com>

* docs(.squad): Scribe session 2026-05-07 - Issue #24 orchestration & decisions

Multi-agent coordination session for issue #24 (Tool Timeout Configuration & Auto-Cancel).
Completed Phase 1 delivery with requirements #1, #2, #3, #8 (config wiring, runtime
propagation, structured logging). Follow-ups tracked separately (stuck-turn, user cancel,
auto-cancel, UI health).

Changes:
- Added orchestration logs for all spawned agents (Leela, Hermes, Bender, Nibbler, Coordinator)
- Added session log for issue #24 multi-agent coordination and outcomes
- Merged decision inbox files into decisions.md, deleted inbox files
- Updated agent history files with issue #24 session outcomes
- Created decisions-archive.md placeholder for future archival (decisions.md: 739KB > 20KB threshold)

Orchestration Outcomes:
- Leela: Architecture review approved (minimal, correct, well-bounded)
- Hermes: TDD test contracts locked and verified (3 regression tests passing)
- Bender: Runtime implementation complete (config→descriptor→runtime wiring)
- Nibbler: Consistency review verified (docs aligned, no cross-agent drift)
- Coordinator: PR submitted (#177)

Test Coverage: All 24 issue-related tests passing (9 AgentCore + 15 Gateway)
PR Status: Ready for merge (awaiting CI)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(.squad): record PR 184 refresh 2 status

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(.squad): fix branch name formatting in PR 184 notes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
sytone added a commit that referenced this pull request May 26, 2026
Three independent issues surfaced by the PR #549 multi-model critique
sweep — security/gpt-5.5, plan-vs-impl/claude-opus-4.6, and
bug-hunt/gpt-5.3-codex — fixed together since they share the receiver
controller and its test surface.

1. MetadataString silent failure (bug-hunt BLOCKING #1).
   The 'value as string' cast returned null when SqliteSessionStore /
   FileSessionStore deserialized object? metadata values as JsonElement
   (System.Text.Json default for object?). This drove every reused
   cross-world session past the 'value is null' guard into a
   one-character-fast-path-fail, then 409 — symptom looked like a
   stale session being rejected for missing ownership, but actually
   the controller couldn't see ownership it should have. Switch to
   the established codebase pattern (string | JsonElement(String) | _)
   already used by AgentConverseTool and PreCompactionMemoryFlusher.

2. Sealed-session silent reactivation (bug-hunt BLOCKING #5).
   ResolveSessionAsync reuse branch did not check Session.Status.
   A sealed session would be 'reused' for new turns even though the
   archive contract says sealed = terminal. Add an explicit
   GatewaySessionStatus.Sealed guard that returns 409 with a clear
   reason; pinned by RelayAsync_WhenRemoteSessionIdSupplied_ButSessionIsSealed_Returns409.

3. Agent-enumeration oracle (security/gpt-5.5 LOW).
   registry.Contains(targetAgentId) ran BEFORE
   inboundAuthService.TryAuthorize, so an unauthenticated probe
   against an unknown agent name returned 404 while a known agent
   name returned 401 — leaking the agent list to anyone with the
   relay endpoint. Reorder: authorise the peer first, return 401
   uniformly for unauthenticated probes regardless of agent
   existence. Pinned by RelayAsync_WithInvalidApiKey_AndUnknownTargetAgent_Returns401_NotEnumerable404.

Adds 7 critique-sweep tests including isolated coverage for each
OwnedByRequester invariant guard (SessionType mismatch, sourceAgentId
mismatch, missing conversation, no-bound-conversation) and the
JsonElement-metadata reuse regression that proves fix (1).

Refs PR #549 critique sweep.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
sytone added a commit that referenced this pull request May 26, 2026
…sionStore

System.Text.Json deserializes Dictionary<string, object?> values as
JsonElement (not their original primitive types). This affects both
SqliteSessionStore AND FileSessionStore — readers must handle either
shape (string OR JsonElement(String)).

Add a docs-pin test that writes a Session.Metadata string value and
asserts the reload is either a string or a JsonElement(String) — both
shapes are acceptable, but downstream code must not assume one or the
other (regression for the bug fixed in CrossWorldFederationController.
MetadataString).

Refs PR #549 critique sweep (bug-hunt BLOCKING #1).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
sytone added a commit that referenced this pull request May 26, 2026
…ore + delete dead communicator + remove SessionId factories (#549)

* feat(gateway): route cross-world receiver through IConversationStore

Phase 4 / F-3 (receiver branch). Mirrors PR #548 (sender) on the
`CrossWorldFederationController.RelayAsync` path so received cross-world
messages now create a real `Conversation` row owned by the local target
agent and the receiver session is pinned to it BEFORE the supervisor is
invoked (persist-before-prompt — same race window the sender PR closed).

Closes the F-6-shaped orphan window on the receiver: previously the relay
allocated a synthetic `SessionId.ForAgentConversation` and never touched
`IConversationStore` at all, so a background flush or portal page-load
during the supervisor turn could see the session with no conversation.

Source identity (`SourceWorldId`, `SourceAgentId`, sender-side
conversation/session ids) is stashed on `Conversation.Metadata` only —
NOT promoted into `Conversation.Title` or `Conversation.Purpose`,
both of which `SystemPromptBuilder.BuildConversationContextSection`
(`SystemPromptBuilder.cs:601`) injects into the target system prompt.
Title is the constant `Cross-world agent exchange`; Purpose is null.
Caller-controlled strings in those positions would be an XPIA vector.

Session reuse: if the sender supplies `RemoteSessionId` from a previous
turn, the receiver validates the supplied id (a) exists (404 if not),
(b) is owned by the target agent (409), (c) is a cross-world AgentAgent
session (409), and (d) was originally minted for the same
`(SourceWorldId, SourceAgentId)` pair (409). Without these checks any
world that can guess a session id could impersonate another world's
transcript.

15 RED-then-GREEN tests (replace the existing 2) covering the full
contract: kind=AgentAgent + Initiator=null, XPIA defence (Title +
Purpose), persist-before-prompt ordering pin via RecordingSessionStore,
eager-pin assignment, returning local sessionId (not echoing sender's),
reuse path with valid RemoteSessionId, all four 409 invariant violations
plus 404 on missing, and failure paths (prompt throws + supervisor
throws). InMemorySessionStore is sealed so the recorder composes via
SessionStoreBase rather than inheriting.

Refs #523.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(architecture): pin Phase 4 / 1b receiver shape with 5 fences

Mirrors AgentExchangeConversationArchitectureTests (sender, PR #548) for
the cross-world receiver. CrossWorldFederationController.cs may not:

1. Call SessionId.ForAgentConversation(...) — the synthetic-encoding
   factory the receiver was built on must stay deleted from this path.
2. Lose its IConversationStore namespace import.
3. Accept a ctor without an IConversationStore dependency.
4. Have any .ConversationId = assignment lexically after the first
   .PromptAsync(...) call IN THE SAME METHOD (catches both inline
   regression and helper-method shape, just like the sender fence).
5. Have its method-splitter regex regress to a shape that doesnt find
   RelayAsync (the self-test makes that silent-pass loud).

Splitter difference vs sender: the receiver controller uses a primary
constructor, so the splitter needs an atomic group on the modifier list
to prevent the regex engine backtracking out of consuming "sealed" —
otherwise "sealed class" folds into the return-type segment and the
class header matches the method pattern. The negative lookahead alone
is not enough without locking the modifier match.

Refs #523.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(gateway): delete dead IAgentCommunicator infrastructure

IAgentCommunicator + DefaultAgentCommunicator have zero production callers
post-#530 — every named-to-sub and named-to-named call now flows through
ISubAgentManager.SpawnAsync (for fire-and-forget) or
IAgentExchangeService.ConverseAsync (for synchronous request/response).
The receiver branch of cross-world federation moved to IConversationStore
earlier in this PR, removing the last reason DefaultAgentCommunicator
existed.

Deleted:
- src/gateway/BotNexus.Gateway.Contracts/Agents/IAgentCommunicator.cs
- src/gateway/BotNexus.Gateway/Agents/DefaultAgentCommunicator.cs
- tests/gateway/BotNexus.Gateway.Tests/DefaultAgentCommunicatorTests.cs (13 facts)
- tests/gateway/BotNexus.Gateway.Tests/CrossAgentCallingTests.cs (6 facts)

Updated:
- GatewayServiceCollectionExtensions.cs:117 — DI registration removed
- AssemblyLoadContextExtensionLoader.cs:44 — extension-loader entry removed
- TypeForwards.cs:52 — type-forward removed
- ISubAgentManager.cs:8 — stale XML cross-ref removed
- BotNexus.Gateway.Abstractions/README.md — row removed

Test-coverage audit confirmed all 19 deleted facts are mirrored by
AgentExchangeServiceTests / AgentExchangeConversationTests EXCEPT the 2
timeout tests (CallCrossAgentAsync_WhenTargetTimesOut_*). AgentExchangeService
uses caller-supplied cancellation tokens instead of the deleted
PromptWithTimeoutAsync wrapper; the timeout-wrapper code path had no
production callers, so the regression risk of dropping it is zero.

Per AGENTS.md "no [Obsolete] attributes — delete dead code instead of
deprecating it" and "do not net-delete tests; migrate them" (audit
documents the migration).

Refs #523.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(domain): delete SessionId.ForAgentConversation and ForCrossAgent

These synthetic-encoding factories had no remaining callers after the
sender (PR #548) and receiver (this PR) migrations routed both branches
through IConversationStore with generic SessionId.Create() ids. Per
AGENTS.md "delete dead code instead of deprecating it" — keeping them
around as documentation-only would re-invite drift.

KEPT: SessionId.IsAgentConversation predicate. SessionStoreBase.InferSessionType
still reads it to bucket pre-migration sessions persisted with the legacy
`::agent-agent::` encoding as SessionType.AgentAgent on read. Added test
IsAgentConversation_OnLegacyEncodedId_StillReturnsTrue_ForBackCompatRead
pinning that contract.

Migrated tests (5 deleted, 1 added):
- SessionIdTests.ForAgentConversation_UsesPinnedFormat — DELETED
- SessionIdTests.ForAgentConversation_WhenUniqueIdIsEmpty_ShouldThrow — DELETED
- SessionIdTests.ForCrossAgent_UsesPinnedFormat — DELETED
- PrimitiveBoundaryTests.SessionId_ForAgentConversation_EmptyUniqueId_Throws — DELETED
- PrimitiveBoundaryTests.SessionId_ForCrossAgent_WithSameSourceAndTarget_Works — DELETED
+ SessionIdTests.IsAgentConversation_OnLegacyEncodedId_StillReturnsTrue_ForBackCompatRead — NEW

Also updated:
- AgentExchangeService.cs:202-204 — stale "receiver will reuse our id" comment
  rewritten to reflect that each world owns its own ConversationId now.
- docs/development/ddd-patterns.md:435 — SessionId factory list updated.

Architecture fences in AgentExchangeConversationArchitectureTests and
CrossWorldReceiverArchitectureTests are now belt-and-braces (compile-time
prevents the call too) but kept as documentation/defensive markers.

Refs #523.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(sessions): round-trip Session.Metadata in FileSessionStore

The SessionMeta sidecar record omitted the Metadata dictionary, so
Session.Metadata silently disappeared on every disk round-trip.

Cross-world federation receiver depends on Session.Metadata
[sourceWorldId] + [sourceAgentId] to validate RemoteSessionId
ownership (see CrossWorldFederationController.OwnedByRequester).
Without the round-trip, every cross-world relay returns 409 after
the gateway restarts on a FileSessionStore-backed deployment.

Add the Metadata field to SessionMeta + round-trip in Write/Load.
Two new tests pin the contract: full key/value round-trip (with the
codebase-standard ExtractMetadataString helper because System.Text.Json
deserializes object? as JsonElement, consistent with SqliteSessionStore)
plus empty-dict survival.

Refs PR #549 critique sweep (bug-hunt BLOCKING #2).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(gateway): harden cross-world receiver per critique sweep

Three independent issues surfaced by the PR #549 multi-model critique
sweep — security/gpt-5.5, plan-vs-impl/claude-opus-4.6, and
bug-hunt/gpt-5.3-codex — fixed together since they share the receiver
controller and its test surface.

1. MetadataString silent failure (bug-hunt BLOCKING #1).
   The 'value as string' cast returned null when SqliteSessionStore /
   FileSessionStore deserialized object? metadata values as JsonElement
   (System.Text.Json default for object?). This drove every reused
   cross-world session past the 'value is null' guard into a
   one-character-fast-path-fail, then 409 — symptom looked like a
   stale session being rejected for missing ownership, but actually
   the controller couldn't see ownership it should have. Switch to
   the established codebase pattern (string | JsonElement(String) | _)
   already used by AgentConverseTool and PreCompactionMemoryFlusher.

2. Sealed-session silent reactivation (bug-hunt BLOCKING #5).
   ResolveSessionAsync reuse branch did not check Session.Status.
   A sealed session would be 'reused' for new turns even though the
   archive contract says sealed = terminal. Add an explicit
   GatewaySessionStatus.Sealed guard that returns 409 with a clear
   reason; pinned by RelayAsync_WhenRemoteSessionIdSupplied_ButSessionIsSealed_Returns409.

3. Agent-enumeration oracle (security/gpt-5.5 LOW).
   registry.Contains(targetAgentId) ran BEFORE
   inboundAuthService.TryAuthorize, so an unauthenticated probe
   against an unknown agent name returned 404 while a known agent
   name returned 401 — leaking the agent list to anyone with the
   relay endpoint. Reorder: authorise the peer first, return 401
   uniformly for unauthenticated probes regardless of agent
   existence. Pinned by RelayAsync_WithInvalidApiKey_AndUnknownTargetAgent_Returns401_NotEnumerable404.

Adds 7 critique-sweep tests including isolated coverage for each
OwnedByRequester invariant guard (SessionType mismatch, sourceAgentId
mismatch, missing conversation, no-bound-conversation) and the
JsonElement-metadata reuse regression that proves fix (1).

Refs PR #549 critique sweep.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(architecture): replace vacuous receiver fence with helper-bypass-aware check

The original per-method fence
NoConversationIdMutation_AfterFirstPromptAsync_InRelayAsync was
vacuously true: it split the file into method bodies, identified the
RelayAsync body, then asserted '.ConversationId = does not appear
after .PromptAsync('. Because the actual mutation lived in the helper
ResolveSessionAsync (called BEFORE PromptAsync) and the prompt lived
inline in RelayAsync, the per-method body of RelayAsync had neither
the assignment nor a regression target — the check always passed
even when the architecture broke (plan-vs-impl HIGH).

Replace with a compound check (matching the actual invariant):

1. Inline check: within RelayAsync, every .ConversationId =
   assignment must lexically precede the first .PromptAsync(.
2. Helper-bypass check: for every helper method H in the file that
   (a) contains .ConversationId = AND (b) is invoked from RelayAsync
   AFTER the first .PromptAsync(, fail.

This catches both regression shapes — inline mutation re-ordering
AND someone extracting the eager pin into a late-stage helper that
mirrors the original F-6 sub-agent eager-pin bug.

Add three self-tests to prove the fence is not vacuous:
- Vacuity guard: assert RelayAsync body is non-empty.
- Synthetic violation: hand-crafted code with late mutation must FAIL.
- Synthetic clean: hand-crafted code with early mutation must PASS.

Refs PR #549 critique sweep.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(sessions): document JsonElement metadata round-trip in SqliteSessionStore

System.Text.Json deserializes Dictionary<string, object?> values as
JsonElement (not their original primitive types). This affects both
SqliteSessionStore AND FileSessionStore — readers must handle either
shape (string OR JsonElement(String)).

Add a docs-pin test that writes a Session.Metadata string value and
asserts the reload is either a string or a JsonElement(String) — both
shapes are acceptable, but downstream code must not assume one or the
other (regression for the bug fixed in CrossWorldFederationController.
MetadataString).

Refs PR #549 critique sweep (bug-hunt BLOCKING #1).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
sytone added a commit that referenced this pull request May 26, 2026
#563)

* feat(domain): introduce SubAgentSpawnMode = Embody | Mirror DU (#562 step 1/5)

Phase 5 / F-6 step 3, first staged commit of the migration to clarify
sub-agent spawn intent at the type level. Today SubAgentSpawnRequest is
a bag of optional fields where the "embody a role" path (Archetype +
SystemPromptOverride + ModelOverride + ApiProviderOverride + ToolIds +
Name) shares record-shape with the "mirror an existing named agent" path
(TargetAgentId), and precedence is implicit in DefaultSubAgentManager.

This commit introduces SubAgentSpawnMode as a closed discriminated union
(abstract sealed record with exactly two derivations: Embody and Mirror)
and threads an OPTIONAL Mode property onto SubAgentSpawnRequest so the
new shape lands side-by-side with the old fields. No production code is
rewired yet — that lands in commits 2 and 3. All old top-level fields
on SubAgentSpawnRequest remain UNCHANGED; they are deleted and Mode
becomes `required` in commit 5.

Design decisions (locked via ask_user after rubber-duck design critique):

  1. Embody is a strict pass-through of role + optional customisations.
     Role-derived defaults (per-role tool sets, prompts) are deferred to
     #467 — EmbodyCustomizations carries no derivation logic and every
     field is nullable with a `static readonly Default = new()` singleton
     for the common "no overrides" case.

  2. Embody.Role is validated against a closed set of the 6 known
     SubAgentArchetype statics (Researcher, Coder, Planner, Reviewer,
     Writer, General). The smart-enum's open FromString registry stays
     intact for back-compat at other call sites, but the Embody record
     itself cannot smuggle unknown roles past construction. Validation
     runs from the [JsonConstructor] so deserialised payloads receive
     the same gate as direct construction.

  3. Mirror is `record Mirror(AgentId TargetAgentId)` — strict, no
     metadata overrides. This is a deliberate behaviour change from
     today: the current code silently passes Name / ModelOverride /
     Archetype / ToolIds through to the mirrored child even though the
     intent is "mirror the target". Mirror clients that previously
     supplied any of these fields will be rejected by the tool layer
     in commit 2 with an explicit mode-mixing error.

  4. The JSON discriminator property is "mode" with values "embody" and
     "mirror", declared via [JsonPolymorphic] + [JsonDerivedType]. No
     production boundary serialises SubAgentSpawnRequest today
     (ServiceBus uses InboundMessage envelopes; IInternalTrigger uses
     InternalTriggerRequest) — the annotations are defensive hygiene
     so future serialisation paths inherit the closed shape.

19 unit tests cover construction (Default singleton identity, all-null
customisation pin, 6-role closed-set Theory, unknown-role rejection,
null-arg rejection, Mirror construction), JSON round-trip both modes
including full customisations, and rejection of missing / unknown
discriminators. Note the missing-discriminator path raises
NotSupportedException (not JsonException) — this is the contract STJ
exposes for polymorphic abstract bases without a discriminator and is
pinned by name.

Out of scope for this commit:
  - SubAgentSpawnTool still constructs the old fields (commit 2).
  - DefaultSubAgentManager.SpawnAsync still pattern-matches on the old
    fields (commit 3).
  - Test callsites still use the old shape (commit 4).
  - Old fields and the architecture fence land in commit 5.

Closes a piece of #562; remainder follows in the staged commit story.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(gateway): SubAgentSpawnTool builds Mode + rejects mode-mixing (#562 step 2/5)

Phase 5 / F-6 step 3, second staged commit. The tool's agent-facing JSON
shape is preserved exactly — the same flat object with task / name /
model / apiProvider / tools / systemPrompt / archetype / targetAgentId —
but the tool now translates that flat shape into the closed
SubAgentSpawnMode union introduced in commit 1 and rejects mode-mixing.

Behavioural changes:

  1. The tool populates request.Mode in every call.
     * targetAgentId present + no embody fields → Mode = Mirror(AgentId.From(targetAgentId)).
     * targetAgentId present + ANY embody field → ArgumentException with
       the list of conflicting field names. The error message names every
       offending field in one shot so the agent can fix the call once,
       not iteratively. This is a deliberate behaviour change from
       today's silent pass-through — Mirror is strict.
     * targetAgentId absent + no embody fields → Mode = Embody(General,
       EmbodyCustomizations.Default). The Default singleton (same
       reference for every call with no overrides) keeps allocation
       at zero for the common case.
     * targetAgentId absent + any embody field → Mode = Embody(role,
       new EmbodyCustomizations { ... }) — only allocates when overrides
       are actually present.

  2. The legacy top-level fields (Name / ModelOverride / etc. /
     TargetAgentId / Archetype) remain populated unchanged. This is
     deliberate — DefaultSubAgentManager.SpawnAsync is still on the old
     read path until commit 3. Both shapes are in sync per call so
     either reader produces identical behaviour during the migration
     window. Step 5 deletes the old shape atomically with making Mode
     `required` and removing the synced writes.

  3. The unused private helper ReadArchetype was removed; the
     responsibility moved into the new BuildSpawnMode pipeline via
     ResolveArchetype (still using SubAgentArchetype.FromString for the
     open-registry back-compat path — Embody.Role's closed-set guard
     enforces the closed shape at the type boundary).

Mode-mixing rejection design (per locked decision strict_reject_all):
the JSON tool layer is the friendliest place to surface the conflict
because the call hasn't yet entered the spawn pipeline. Throwing
ArgumentException flows through the agent loop's ToolExecutor as an
IsError=true tool result with the message body — the calling LLM sees
"don't do that, here's why" and can self-correct in the next turn.

10 new unit tests:
  - Mode = Embody(General, Default) on the bare-minimum {task} call;
    the Default singleton identity is pinned via ShouldBeSameAs so the
    no-overrides path stays allocation-free.
  - Mode = Embody(role, Default) when only archetype is supplied
    (reviewer → Reviewer).
  - Mode = Embody(role, custom) with every embody field populated
    (Coder + name + model + apiProvider + tools + systemPrompt) — pins
    the full round-trip of every customisation field through the tool
    layer.
  - Mode = Mirror(AgentId) when only targetAgentId is supplied; the
    AgentId.Value is pinned so the Vogen wrapper isn't a no-op.
  - Mode-mixing rejection: a Theory over name / model / apiProvider /
    systemPrompt / archetype each pairs targetAgentId with one of the
    embody fields and pins the rejection message naming both fields.
  - Mode-mixing rejection: tools array (separate because the value
    shape differs from the string-Theory above).
  - Mode-mixing rejection: multiple conflicts in one call are all
    reported in a single error message (covers the "one error per
    call, not iterative fix" UX guarantee).

Full Gateway.Tests suite: 1861 passed / 1 skipped / 0 failed / 0
warnings under TreatWarningsAsErrors. The legacy assertions on the old
top-level fields (SpawnTool_SpawnsSubAgent_WithDefaults etc.) still
pass — back-compat is intact.

Refs #562. Next: commit 3 (DefaultSubAgentManager.SpawnAsync prefers
Mode over the legacy fields).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(gateway): DefaultSubAgentManager.SpawnAsync pattern-matches on Mode (#562 step 3/5)

Phase 5 / F-6 step 3 of 5. Routes the production spawn path
(DefaultSubAgentManager.SpawnAsync) through the SubAgentSpawnMode
discriminated union introduced in step 1 and constructed by
SubAgentSpawnTool in step 2.

What changed

* DefaultSubAgentManager.SpawnAsync: the inline reads of
  request.Archetype / request.TargetAgentId / request.Name /
  request.ModelOverride / request.ApiProviderOverride / request.ToolIds /
  request.SystemPromptOverride are replaced by a single
  `switch (request.Mode)` that resolves 7 typed locals (archetype,
  baseDescriptor, childAgentId, name, modelOverride,
  apiProviderOverride, toolIds, systemPromptOverride) once.
* The Embody arm uses the parent descriptor as the base and pins the
  archetype value into the child agent id role slot:
  `{parent}--subagent--{archetype}--{uniqueId}`.
* The Mirror arm looks up the target descriptor via the agent registry
  (KeyNotFoundException if absent — fail-fast surfaces misconfiguration
  rather than silently degrading) and pins the target id into the role
  slot: `{parent}--subagent--{targetAgentId}--{uniqueId}`. Per the
  locked design (#562), Mirror is strict pass-through — no embody
  customisations leak through.
* A `case null:` arm preserves the legacy field-population code path
  exactly so the ~12 existing test callsites that still set the
  top-level fields keep passing under back-compat through step 4.
* A `default:` arm throws InvalidOperationException with the unknown
  subclass name — guards against silent acceptance if a third
  SubAgentSpawnMode subtype is added without updating SpawnAsync.
* SubAgentInfo.Name and .Model now read from the resolved `name` /
  `modelOverride` locals (was request.Name / request.ModelOverride),
  ensuring the typed Mode arms drive what callers see post-spawn.

Tests

* tests/gateway/BotNexus.Gateway.Tests/Agents/SubAgentSpawnModeRoutingTests.cs
  NEW. 4 [Fact]s pin the Mode-driven SpawnAsync contract:
    - Embody clones parent + lands archetype in role slot.
    - Embody customisations propagate through to SubAgentInfo.Name +
      .Model (proves typed Mode actually drives the post-spawn surface).
    - Mirror uses target descriptor base + lands target id in role
      slot (locked-design pin: differentiates mirror from same-parent
      role spawn).
    - Mirror with unknown target throws KeyNotFoundException
      (fail-fast vs silent-degrade).

Validation

* Full Gateway.Tests suite: 1865 passed / 1 skipped / 0 failed /
  0 warnings under TreatWarningsAsErrors=true (up from 1861 baseline;
  +4 new mode-routing pins).
* Existing SubAgentTargetAgentIdTests + SubAgentToolTests (24 facts
  total) stay green under the case-null back-compat arm — no
  regressions introduced.

Refs #562

* test(gateway): migrate legacy SubAgentSpawnRequest callsites to set Mode (#562 step 4/5)

Phase 5 / F-6 step 4 of 5. Flips every test that calls
`manager.SpawnAsync(...)` from setting the optional top-level fields
(TargetAgentId / Archetype) to setting the typed
`Mode = new Embody(...)` / `Mode = new Mirror(...)` discriminated
union introduced in step 1 + threaded through the production paths
in steps 2-3.

Files migrated (10 callsites across 4 files):

* Agents/SubAgentArchetypeIdentityTests.cs — both spawns:
    - Archetype = Reviewer → Mode = new Embody(Reviewer)
    - default → Mode = new Embody(General)
* Agents/SubAgentEagerConversationPinTests.cs — all 3 spawns:
    - default → Mode = new Embody(General)
* Agents/SubAgentKindTests.cs:
    - SpawnAsync_MirrorOfNamedAgent_*: TargetAgentId = "researcher-prime"
      → Mode = new Mirror(AgentId.From("researcher-prime"))
    - CreateSpawnRequest helper: default → Mode = new Embody(General)
* Agents/SubAgentTargetAgentIdTests.cs — all 4 spawns:
    - TargetAgentId = "farnsworth" → Mode = new Mirror(AgentId.From("farnsworth"))
    - TargetAgentId = "ghost-agent" → Mode = new Mirror(AgentId.From("ghost-agent"))
    - no TargetAgentId → Mode = new Embody(General)
    - TargetAgentId = "farnsworth" (deny-list test) → Mode = new Mirror(...)

Out of scope for this step:

* Agents/SubAgentModelsTests.cs — pure domain-record reflection /
  equality tests, no SpawnAsync invocation. Its
  `RequiredProperties_AreMarkedRequired` test and the two `new
  SubAgentSpawnRequest` callsites will be updated in step 5 when the
  legacy fields are deleted and Mode becomes required.

Why the SubAgentKindTests Mirror child id slot did NOT need test
adjustment: ResolveChildAgentId(registry, spawned.SubAgentId) searches
descriptors by substring against the per-spawn uniqueId, not against
the role/target slot. So the slot-encoding change from
`<parent>--subagent--<archetype>--<unique>` to
`<parent>--subagent--<targetId>--<unique>` (locked design, #562) is
transparent to the helper. No assertions on the role/target slot
existed in the migrated test surface beyond what already lives in
SubAgentSpawnModeRoutingTests.cs (added in step 3).

Validation

* Full Gateway.Tests suite: 1865 passed / 1 skipped / 0 failed /
  0 warnings under TreatWarningsAsErrors=true. Same total as step 3
  (1865) — net behaviour preserved by the typed Mode path; no
  silent regression in any of the 10 migrated tests.
* Full solution build clean (0 W / 0 E).

After this step the `case null:` back-compat arm in
DefaultSubAgentManager.SpawnAsync has no remaining test callers — it
will be deleted in step 5 along with the legacy fields themselves.

Refs #562

* feat(gateway): delete legacy SubAgentSpawnRequest fields; Mode is required (#562 step 5/5)

Final destructive step of the SubAgentSpawnMode discriminated-union work.
The bag of optional top-level fields on SubAgentSpawnRequest has been
deleted; every spawn must now pick a Mode (Embody | Mirror) at construction
time. The mode-mixing class of bugs (callers populating an inconsistent
subset of legacy fields that the runtime silently interpreted) is closed
structurally — at the type, at the manager, and at the architecture fence.

What changed
- SubAgentSpawnRequest: delete Name, ModelOverride, ApiProviderOverride,
  ToolIds, SystemPromptOverride, Archetype, TargetAgentId. Mode is now
  `required SubAgentSpawnMode`. Kept: ParentAgentId, ParentSessionId, Task,
  MaxTurns, TimeoutSeconds, SpawnDepth, ParentToolDenyList,
  InheritedConversationId, Mode.
- SubAgentSpawnTool: stop populating the deleted fields on the constructed
  request; build the typed Mode (Embody with customisations or Mirror with
  target) and hand it off.
- DefaultSubAgentManager.SpawnAsync: delete the legacy `case null:` arm of
  the switch — Mode being required makes it structurally unreachable. The
  default arm is retained for the "unknown future subclass of
  SubAgentSpawnMode" guard. Deny-list check moved to run AFTER the switch
  so it reads the typed `toolIds` local resolved from the active arm rather
  than a now-deleted top-level field; check stays mode-agnostic so a future
  Mirror that produces a tool list is still policed.

Tests
- SubAgentModelsTests: required-props list now includes Mode; defaults
  test constructs with `Mode = new Embody(SubAgentArchetype.General)` and
  asserts the Mode round-trips; record-equality test uses an Embody mode
  in place of the deleted Name field.
- Migrated remaining test factory helpers to set Mode:
  SubAgentToolInheritanceTests, DefaultSubAgentManagerActivityTests,
  DefaultSubAgentManagerTests (+ scaffold InterfaceBackedSubAgentManager
  reads name/model from Embody customisations), SubAgentCompletionWakeUpTests,
  SubAgentCompletionWakeDeliveryTests, SubAgentIntegrationTests,
  SubAgentSpawnDepthTests.
- SubAgentToolTests: assertions rewritten from `captured.ModelOverride` /
  `.ToolIds` / `.SystemPromptOverride` / `.Archetype` to `captured.Mode`
  pattern-matched as Embody with the customisations and role verified.

New architecture fence
- tests/architecture/BotNexus.Architecture.Tests/SubAgentSpawnRequestShapeTests.cs:
  - SubAgentSpawnRequest_DoesNotExpose_LegacyFields — reflection-asserts
    each of the seven deleted properties is absent.
  - SubAgentSpawnRequest_Mode_IsRequired_AndTypedAsSubAgentSpawnMode —
    asserts Mode exists, is typed SubAgentSpawnMode, carries
    RequiredMemberAttribute, and is non-nullable per
    NullabilityInfoContext.
  - DefaultSubAgentManager_PatternMatchesOnMode_NotLegacyFields —
    source-scan asserts the manager pattern-matches on `request.Mode` and
    does not read any deleted legacy field via `request.<Name>`.
  - Fence_DetectsSyntheticLegacyFieldRead — vacuity guard: the
    legacy-field scan must catch a synthetic violation so a broken regex
    cannot silently neuter the fence.

Validation
- dotnet build BotNexus.slnx --nologo --tl:off → clean (0 warnings,
  0 errors under TreatWarningsAsErrors).
- dotnet test tests/gateway/BotNexus.Gateway.Tests → 1865 passed /
  0 failed / 1 skipped (unchanged from steps 3+4).
- dotnet test tests/architecture/BotNexus.Architecture.Tests →
  53 passed / 0 failed (baseline 49 + 4 new shape pins).

Refs #562

* fix(gateway): fold critique-sweep findings into SubAgentSpawnMode DU (#562)

Multi-model critique sweep on the step-5 destructive-cleanup commit
(36450e4) surfaced two HIGH and two MEDIUM findings. This commit folds
the actionable subset into source + tests; deferred items are documented
inline.

Source fixes
------------

1. Null-Mode runtime guard (bug-hunt HIGH).

   `required` is a compile-time guarantee only. A caller using `null!`
   or a JSON deserializer quirk could surface a runtime null Mode. The
   default switch arm would dereference `request.Mode.GetType()` and
   raise NullReferenceException — which propagates as HTTP 500 with an
   unhelpful stack trace. Added an `ArgumentNullException.ThrowIfNull`
   on `request.Mode` immediately after the existing request null-check
   so callers see a clean, named exception with the parameter name.

2. Mirror model fallback bug (bug-hunt MEDIUM).

   `SubAgentInfo.Model` was computed as
       `modelOverride ?? configuredDefaultModel ?? parentDescriptor.ModelId`
   on both Embody and Mirror paths. For Mirror with no modelOverride,
   this reported the *parent's* model id instead of the *mirrored
   target's* — silent metadata corruption that downstream telemetry,
   cost accounting, and observability would compute against the wrong
   model. Changed to `baseDescriptor.ModelId`, which covers both arms
   correctly (Embody: baseDescriptor = parentDescriptor; Mirror:
   baseDescriptor = mirrored target).

Test fixes
----------

3. Architecture fence regex tightening (plan-vs-impl HIGH).

   The step-5 fence on `DefaultSubAgentManager.cs` used literal
   `source.Contains(...)` to detect the Mode pattern-match. Mutations
   like `switch(request.Mode)` (no space) or `case Embody embody`
   would silently slip past, downgrading the fence to a no-op while
   leaving the suite green. Replaced with regexes:
       switch\s*\(\s*request\.Mode\s*\)
       \b(is|case)\s+Embody\b
       \b(is|case)\s+Mirror\b
   Tolerates IDE / formatter whitespace variations and both `case`
   and `is` pattern shapes.

4. Vacuity guard for the switch-detection clause (plan-vs-impl HIGH).

   The step-5 vacuity test (`Fence_DetectsSyntheticLegacyFieldRead`)
   only covered the legacy-field-read clause. Added a sibling
   `Fence_DetectsSyntheticModeSwitchShapes` that pins the regex
   behaviour against four synthetic mutations (no-space, padded parens,
   `is Embody`, `case Mirror`) and against a clean shape (must not
   false-positive). Without this, a regex regression could silently
   downgrade the fence while the legacy-field scan continues to pass.

5. JSON null-discriminator test pin (plan-vs-impl MEDIUM).

   Existing tests pinned missing discriminator (NotSupportedException)
   and unknown discriminator (JsonException). The `{"mode": null}`
   shape was not pinned. Added `Mode_JsonDeserialize_NullDiscriminator_Throws`
   asserting JsonException. A future STJ behaviour change for null
   discriminators would now be caught immediately.

6. Mirror-model assertion fold-in to routing test.

   `SpawnAsync_WithModeMirror_UsesTargetDescriptor_AndChildIdUsesTargetSlot`
   already pinned the *registered descriptor's* ModelId but not the
   *returned* `info.Model`. Added the assertion so a regression to the
   parent's model id (the bug fixed in #2 above) fails this test
   directly rather than slipping through.

7. Null-Mode test pin (bug-hunt HIGH).

   Added `SpawnAsync_WithNullMode_ThrowsArgumentNullException` to pin
   the new runtime guard at the manager boundary.

Deferred items
--------------

* Security HIGH (Mirror authz gap): a caller with `spawn_subagent`
  can name any registered targetAgentId and inherit that agent's
  tools / model / system-prompt without authz. Authorization is out
  of scope for this plan (operator owns auth; only agent-provider
  auth is in scope). Operators control Mirror target registration —
  treat the registry as a trust boundary.
* Plan-vs-impl HIGH #1: archetype context-table mismatch (my notes
  said "Tester", code says "Writer"). Code is correct; documentation
  fix only, not a behavioural defect.
* Plan-vs-impl LOW #6/#7: out-of-scope SubAgentInfo contract changes
  for Mirror's `Archetype = General` label.
* Plan-vs-impl MEDIUM #4: Mirror inherits target's tools that may
  be on the parent's deny-list (intentional pass-through; deny-list
  is mode-agnostic by design — Mirror is strict pass-through, target
  registration is the trust boundary).

Validation
----------

* Build: 0 warnings / 0 errors under TreatWarningsAsErrors.
* Gateway.Tests: 1867 passed / 1 skipped / 0 failed (+2 from step 5).
* Architecture.Tests: 54 passed / 0 failed (+1 vacuity guard from
  step 5's 53).

Refs #562.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
sytone added a commit that referenced this pull request May 27, 2026
) (#565)

Caller-initiated cancellation of a cross-world relay used to permanently
seal the session via the catch-all `catch (Exception)` block that fired
on every OCE — including the caller's own. The sealed-session 409 guard
in `ResolveSessionAsync` then turned a transient client timeout or a
closed browser tab into a permanently broken sender-retry contract.

Insert a guarded re-throw BEFORE each catch-all so caller-initiated
cancellation rethrows without sealing:

    catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
    {
        // #553: caller-initiated cancellation must NOT seal the session.
        throw;
    }
    catch (Exception ex)
    {
        // existing seal-on-error logic, now only sees inner-token cancels
        // and genuine failures.
    }

The `when` filter is the discriminator: inner-token cancellations
(downstream HTTP-client timeouts linked into the supervisor token) MUST
still flow through to the catch-all and seal, because they are genuine
downstream failures, not callers pulling out.

Patched sites:
- CrossWorldFederationController.ExecuteRelayAsync
- AgentExchangeService.ConverseAsync           (local agent-agent path)
- AgentExchangeService.ConverseAsync           (cross-world relay-out path)

Acceptance criteria (#553) pinned by behavioural tests:
- AC #1 — cancelled relay throws OCE, session stays Active:
    RelayAsync_WhenCallerCancelsDuringPromptAsync_RethrowsOce_AndDoesNotSealSession
    ConverseAsync_LocalPath_WhenCallerCancelsDuringPromptAsync_RethrowsOce_AndDoesNotSealSession
- AC #2 — no error metadata recorded on cancellation:
    same tests assert Metadata.ContainsKey("error").ShouldBeFalse()
- AC #3 — retry with same RemoteSessionId succeeds (exercises the real
  RelayAsync + ResolveSessionAsync reuse path including the
  OwnedByRequester / 5-invariant ownership check from PR #549):
    RelayAsync_AfterCallerCancellation_SessionIsReusableByRetryWithSameRemoteSessionId

Vacuity guard pinning the `when` filter (an inner CTS unrelated to the
caller cancels, OCE bound to inner; caller's IsCancellationRequested is
false ⇒ filter false ⇒ falls through to catch-all ⇒ seals. Removing the
filter would catch the inner OCE and rethrow without sealing, failing
this test):
    RelayAsync_WhenInnerTokenCancels_NotCallerToken_StillSealsSession
    ConverseAsync_LocalPath_WhenInnerTokenCancels_NotCallerToken_StillSealsSession

Architecture fitness fence (CancelNoSealArchitectureTests):
- Per-catch fence on both production files: every `catch (Exception)`
  body containing `GatewaySessionStatus.Sealed` must be preceded by the
  required OCE-rethrow clause within the prior 1500 chars.
- Counter-fence on both production files: each file must contain ≥N
  Sealed-writing catch-alls, so a future refactor to a helper (e.g.
  SealSessionAsync(session)) is forced to either keep the literal or
  update the fence regex — otherwise the per-catch fence would silently
  pass with zero candidates.
- Vacuity guards: synthetic catch-all without OCE guard, synthetic bare
  OCE catch without `when` filter, both must violate.
- False-positive guards: canonical post-#553 shape and canonical shape
  with a brace-bearing comment inside the rethrow body must NOT violate.
  Body regex permits ONE level of nested braces so explanatory comments
  like `// see Foo {...}` don't push future authors to delete them.
- Detector specificity: positive-shape detection and non-sealing catch
  (e.g. ClearActiveSessionAsync diagnostic catches) correctly ignored.

Trade-offs and follow-ups:
- ActiveSessionId is intentionally NOT cleared on caller cancellation.
  A fast retry sees the conversation as still in-flight rather than
  briefly flipping idle → active in the portal. Staleness is bounded by
  SessionCleanupService TTL (default 24h, 5m sweep) and ActiveSessionId
  is documented as a diagnostic, not a correctness contract.
- Catch-all SaveAsync calls pass CancellationToken.None on all three
  sites, so caller-token OCE cannot fire during the seal write — the
  race that would leave Status mutated in-memory but unflushed is
  structurally avoided.
- ClearActiveSessionAsync diagnostic catches (controller, service) are
  deliberately outside the fence's scope — they swallow without sealing.
- Cancelled turn's user-input is persisted before PromptAsync; on retry
  the user message would be appended again. This is a real but bounded
  duplicate-message UX/transcript concern (NOT a security issue per the
  security review) and is filed as a deferred follow-up — solving it
  requires per-turn idempotency keys, which is out of scope for the
  cancel-no-seal fix.

Multi-model critique sweep was run before opening:
- security-review (gpt-5.5): no vulnerabilities; DoS bounded by TTL
  sweeper, authz boundary preserved.
- plan-vs-impl review (claude-opus-4.7): all 3 ACs + `when` filter +
  rethrow + 3-site coverage + fence non-vacuity pinned. Adopted two
  SHOULD-CONSIDER findings (regex loosening for brace-bearing comments,
  counter-fence on Sealed catch count).
- bug-hunt (gpt-5.3-codex): no blocking issues. Confirmed lock scope is
  released by `await using`, token identity is the method parameter,
  vacuity guard truly pins the discriminator.

Closes #553.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sytone

sytone commented Jun 2, 2026

Copy link
Copy Markdown
Owner

PR Parallelism Map — 2026-06-02

✅ Can merge in parallel (independent)

PR Title Component Linked Issue
#712 feat(skills): add Skills Explorer portal section with gateway API Portal/BlazorClient + Gateway API None linked

⏩ Must merge sequentially (dependencies)

PR Title Blocks / Blocked By Reason
#715 refactor(signalr): wire GatewayHub onto IInboundMessageOrchestrator Closes #714 / Blocked by PR3 (#701, already merged) Depends on IInboundMessageOrchestrator seam from PR3

⚠️ Conflicts detected

PR Conflicting With Shared Files
#712 #715 No shared files (Skills: BlazorClient/Gateway; #715: GatewayHub/tests only)

Notes:

@sytone

sytone commented Jun 3, 2026

Copy link
Copy Markdown
Owner

PR Parallelism Map — 2026-06-03

✅ Can merge in parallel (independent)

PR Title Component Linked Issue
#751 fix(e2e): audit and align all E2E tests to current UI design E2E Tests only None (standalone audit)
#741 feat(ui): remove mic button - audio recording not yet supported Portal/Blazor Client None explicit
#727 test(e2e): mobile chat coverage — scroll and error bar regression tests E2E Tests + Mobile Closes #722, #723

⏩ Must merge sequentially (dependencies)

PR Title Blocks / Blocked By Reason
#727 Mobile E2E coverage Blocked by actual fixes for #722/#723 Tests will fail until chatScroll.js is added to mobile index.html (#722) and dismiss button added to blazor-error-ui (#723). Tests pass once fixes are in.

⚠️ Conflicts detected

PR Conflicting With Shared Files
#751 #727 Both touch tests/integration/BotNexus.Integration.E2E.Tests/PortalPage.cs, SlashCommandTests.cs, SidebarNavigationTests.cs are modified by #727 while #751 modifies other files in the same directory. Review for merge order: merge #727 first or coordinate.

Notes

@sytone

sytone commented Jun 3, 2026

Copy link
Copy Markdown
Owner

PR Parallelism Map — 2026-06-03

3 open PRs: #751, #741, #727

✅ Can merge in parallel (independent)

PR Title Component Linked Issues
#741 feat(ui): remove mic button BlazorClient ChatPanel + app.css none (self-contained cleanup)
#727 test(e2e): mobile chat coverage E2E tests (new files only) #722, #723

⏩ Must merge sequentially (dependencies / conflicts)

PR Title Blocks / Blocked By Reason
#751 fix(e2e): audit and align all E2E tests conflicts with #727 Both modify shared E2E files
#727 test(e2e): mobile chat coverage conflicts with #751 Both modify shared E2E files

⚠️ Conflicts detected

PR Conflicting With Shared Files
#751 #727 PageObjects/PortalPage.cs, SlashCommandTests.cs, SidebarNavigationTests.cs
#727 #751 same

Recommendation: Merge #741 immediately (no conflicts). For #751 and #727 — pick one to merge first; the other needs a rebase before it can land.

@sytone

sytone commented Jun 4, 2026

Copy link
Copy Markdown
Owner

PR Parallelism Map — 2026-06-04

Open PRs

PR Title Branch Linked Issue Status
#796 test(e2e): full portal coverage expansion test/e2e-full-coverage None (standalone test expansion) UNSTABLE (CI running)

✅ Can merge in parallel (independent)

PR Title Component Linked Issue
#796 E2E full portal coverage E2E Tests only None

⏩ Must merge sequentially

No sequential dependencies detected — only 1 open PR.

⚠️ Conflicts detected

None — #796 touches only E2E test files and one ChatPanel.razor change. No infrastructure files (Program.cs, *.slnx, Directory.Build.props).

Note: #796 has been through multiple CI fix iterations today (commits d554bf168d1e549d39800). CI run 26958751516 in progress. Recommend waiting for green before merge.

@sytone

sytone commented Jun 4, 2026

Copy link
Copy Markdown
Owner

`

@sytone

sytone commented Jun 4, 2026

Copy link
Copy Markdown
Owner

PR Parallelism Map -- 2026-06-05

Open PRs

PR Title Branch Status
#821 feat(gateway): fire SignalR push on ConversationTool metadata mutations feat/conversation-tool-notifier OPEN, conflict resolved, CI re-running
#826 fix(gateway): isolate session transcript save from channel send delivery fix/channel-send-transcript-isolation OPEN, CI running
#827 fix(portal): route TurnEnd event through SignalR to clear streaming state fix/signalr-turn-end-routing OPEN, CI running

Can merge in parallel (no file overlap)

Recommended merge order

#826 first (smallest, GatewayHost only), then #821 (requires #820 already merged -- done), then #827. Any order is safe.

@sytone

sytone commented Jun 5, 2026

Copy link
Copy Markdown
Owner

PR Parallelism Map -- 2026-06-05 (updated)

Open PRs

PR Title Branch Files
#835 feat(cli): scaffold heartbeat config by default in init and agent add feat/cli-heartbeat-defaults Cli only
#834 feat(gateway): heartbeat enabled by default and config normalisation feat/heartbeat-config-normalisation Domain + Gateway
#833 fix(gateway): deduplicate user turns in cross-world relay fix/exchange-dedup-user-turn CrossWorld files
#832 fix(gateway): replace blocking DispatchAsync with Post in ConversationTool fix/conversation-tool-post ConversationTool + Orchestrator
#830 fix(cli): distinguish auth failure from unreachable in gateway status fix/gateway-status-auth-distinction GatewayCommand + GatewayProcessManager
#829 fix(portal): wrap async agent-switch assertion in WaitForAssertion fix/mainlayout-async-assertion MainLayoutTests only
#827 fix(portal): route TurnEnd event through SignalR fix/signalr-turn-end-routing IGatewayHubClient, SignalRChannelAdapter, GatewayHubConnection, GatewayEventHandler
#836 fix(cron): defer user entry save to after PromptAsync fix/cron-duplicate-user-message CronTrigger.cs + CronTriggerTests.cs
#837 fix(agents): deterministic probes in ToolExecutorTimeoutTests fix/tool-executor-deterministic-tests ToolExecutorTimeoutTests.cs only

Can merge in parallel (no file overlap)

Merge order recommendation

Any order is safe across all 9 PRs. No stacking dependencies.

@sytone

sytone commented Jun 5, 2026

Copy link
Copy Markdown
Owner

PR Parallelism Map -- 2026-06-05 (updated)

Open PRs

PR Title Branch Files
#835 feat(cli): scaffold heartbeat config by default in init and agent add feat/cli-heartbeat-defaults Cli only
#834 feat(gateway): heartbeat enabled by default and config normalisation feat/heartbeat-config-normalisation Domain + Gateway
#833 fix(gateway): deduplicate user turns in cross-world relay fix/exchange-dedup-user-turn CrossWorld files
#832 fix(gateway): replace blocking DispatchAsync with Post in ConversationTool fix/conversation-tool-post ConversationTool + Orchestrator
#830 fix(cli): distinguish auth failure from unreachable in gateway status fix/gateway-status-auth-distinction GatewayCommand + GatewayProcessManager
#829 fix(portal): wrap async agent-switch assertion in WaitForAssertion fix/mainlayout-async-assertion MainLayoutTests only
#827 fix(portal): route TurnEnd event through SignalR fix/signalr-turn-end-routing IGatewayHubClient, SignalRChannelAdapter, GatewayHubConnection, GatewayEventHandler
#836 fix(cron): defer user entry save to after PromptAsync fix/cron-duplicate-user-message CronTrigger.cs + CronTriggerTests.cs
#837 fix(agents): deterministic probes in ToolExecutorTimeoutTests fix/tool-executor-deterministic-tests ToolExecutorTimeoutTests.cs only
#839 fix(gateway): fall back to ActiveSessionId in GetHistory when session lacks conversation_id fix/history-orphaned-session ConversationsController.cs + ConversationsControllerHistoryTests.cs
#840 fix(portal): clear stale streaming state and reload history on SelectConversation fix/stale-streaming-state AgentInteractionService.cs + AgentInteractionServiceTests.cs

Can merge in parallel (no file overlap)

Merge order recommendation

Any order is safe across all 11 PRs. No stacking dependencies.

@sytone

sytone commented Jun 5, 2026

Copy link
Copy Markdown
Owner

PR Parallelism Map — 2026-06-05 (morning)

Open PRs

PR Branch Component CI Conflict Issues
#841 fix/agent-panel-tab-deeplink Portal (AgentPanel.razor) ⏳ running CLEAN Closes #637
#842 fix/client-error-reporting Portal (GlobalErrorBoundary, MainLayout) + Gateway (DiagnosticsController) ⏳ running CLEAN Closes #814
#840 fix/stale-streaming-state Portal (AgentInteractionService) ❌ UNSTABLE (flaky CI — Clicking_burger_twice_closes_sidebar) CLEAN Closes #789
#839 fix/history-orphaned-session Gateway (ConversationsController) ✅ PASS CLEAN
#837 fix/tool-executor-deterministic-tests Tests (ToolExecutorTimeoutTests) ✅ PASS CLEAN Closes #621
#836 fix/cron-duplicate-user-message Gateway (CronTrigger) ✅ PASS CLEAN Closes #656
#835 feat/cli-heartbeat-defaults CLI (AgentCommands, InitCommand) ✅ PASS CLEAN Closes #823
#834 feat/heartbeat-config-normalisation Gateway (HeartbeatAgentConfig, ConfigNormalisationHostedService) ✅ PASS CLEAN Closes #822
#833 fix/exchange-dedup-user-turn Gateway (AgentExchangeService, CrossWorldFederationController) ✅ PASS CLEAN Closes #566
#832 fix/conversation-tool-post Gateway (ConversationTool, InProcessIsolationStrategy) ✅ PASS CLEAN Closes #728
#830 fix/gateway-status-auth-distinction CLI (GatewayCommand, GatewayProcessManager) ✅ PASS CLEAN Closes #757
#829 fix/mainlayout-async-assertion Tests (MainLayoutTests) ✅ PASS CLEAN
#827 fix/signalr-turn-end-routing SignalR (IGatewayHubClient, SignalRChannelAdapter, GatewayEventHandler) ✅ PASS CLEAN Closes #668

Merge order recommendations

Standalone (any order):

Sequential (stacking pairs):

Dependency-free pairs for this cycle:

File overlap map

sytone added a commit that referenced this pull request Jun 9, 2026
…on (#1063)

NormalizeFuzzyMatchWithMap iterated char-by-char and called
.Normalize(NormalizationForm.FormKC) on individual characters.
When a character was a high surrogate (first half of an emoji
surrogate pair), .Normalize() threw ArgumentException because
lone surrogates are not valid Unicode code points.

Fix: detect surrogate pairs and emit them together without
normalization. Lone surrogates (invalid Unicode) are replaced
with U+FFFD to prevent crashes on corrupt files.

This was the #1 failure mode across all file tools (113
occurrences, 77% of all edit errors in session DB).

Closes #1061

Co-authored-by: BotNexus Test <botnexus@test.local>
@sytone

sytone commented Jun 20, 2026

Copy link
Copy Markdown
Owner

PR Parallelism Map -- 2026-06-20

10 open PRs. Every PR touches a fully disjoint file set -- zero shared files across the entire queue. All 10 are file-independent and can merge in any order. The only sequencing note is a CI-unblock ordering (not a file conflict): #1543/#1544/#1546 currently show UNSTABLE because they were branched off pre-#1539 main and inherit the unpatchable NU1903 / GHSA-2m69-gcr7-jv3q SQLite advisory that aborts dotnet restore. They go green automatically once #1539 merges -- no rebase, no code change. CodeQL + all Analyze + Secret Scan are SUCCESS on all three; only Dependency Security Audit + impacted-tests fail at restore.

✅ Can merge in parallel (independent -- no shared files, no issue-level dependency)

PR Title Component Linked Issue
#1529 ci(security): guard for security-sensitive boundary files CI / .github #1525
#1530 perf(persistence): bound SQLite session/conversation caches + lock pools Gateway Persistence #1504
#1531 refactor(gateway): extract PrepareTurnAsync from ProcessAsync Gateway Host #1503
#1533 feat(security): SecurityEvent model + trusted ring-buffer sink Gateway Contracts #1532
#1534 docs: backfill v0.7.0/v0.8.0 release pages + CLI reference Docs only --
#1537 fix(cli): align port-availability probe with wildcard bind CLI #1536
#1539 fix(ci): unblock main restore on SQLite advisory + repair auto-issue CI / Directory.Packages.props #1538
#1543 refactor(providers): unify completions converter into Core Providers.Core/OpenAI/Copilot #1540
#1544 refactor(gateway): split cross-world federation out of AgentExchangeService Gateway Agents #1542
#1546 refactor(providers): unify Responses stream parsers into Core Providers.Core/OpenAI/Copilot #1545

⏩ Must merge sequentially (dependencies)

PR Title Blocks / Blocked By Reason
#1539 fix(ci): SQLite advisory suppress CI-unblocks #1543, #1544, #1546 Not a file conflict -- those 3 inherit the NU1903 restore-abort from pre-#1539 main. Merging #1539 first clears their UNSTABLE state with no rebase. Recommend merging #1539 first.

⚠️ Conflicts detected

None. Pairwise shared-file scan across all 10 PRs returned zero overlaps. The only infra-file touchers are singletons: Directory.Packages.props (#1539 only), GatewayServiceCollectionExtensions.cs (#1544 only), GatewayHost.cs (#1531 only) -- no contention.

Notes

@sytone

sytone commented Jun 21, 2026

Copy link
Copy Markdown
Owner

PR Parallelism Map — 2026-06-21

10 open PRs, all MERGEABLE/CLEAN. File-set analysis (gh pr view --json files, all 10 cross-compared): zero shared files, zero high-conflict infra-file touches (no Program.cs / *.slnx / Directory.*.props / migrations). Every PR is a disjoint component slice.

✅ Can merge in parallel (independent — all 10)

PR Title Component Linked Issue
#1529 ci(security): guard for security-sensitive boundary files CI/Workflows (security-sensitive-guard.*, CODEOWNERS) #1525
#1530 perf(persistence): bound SQLite session/conversation caches + lock pools Gateway/Persistence (SqliteSessionStore.cs, SqliteConversationStore.cs) #1504
#1531 refactor(gateway): extract PrepareTurnAsync from ProcessAsync Gateway/Host (GatewayHost.cs) #1503
#1533 feat(security): SecurityEvent model + trusted ring-buffer sink Gateway/Security (Contracts/Security/*) #1532
#1534 docs: backfill v0.7.0+v0.8.0 release pages + CLI surface fixes Docs (docs/**, path-filtered CI)
#1537 fix(cli): align port-availability probe with wildcard bind CLI (Serve/UpdateCommand.cs) #1536
#1543 refactor(providers): unify completions converter into Core Agent/Providers (Completions) #1540 (slice 1)
#1544 refactor(gateway): split cross-world routing out of AgentExchangeService Gateway/Agents (Agents/*) #1542
#1546 refactor(providers): unify Responses stream parsers into Core Agent/Providers (Responses) #1545 (= #1540 slice 2)
#1549 ci(workflows): per-ref concurrency groups CI/Workflows (ci-build-test/ci-container-integration/security-codeql.yml) #1548

⏩ Must merge sequentially

None. No PR's linked issue is a Part of/Blocked by/Depends on dependency of another's. No domain-model-producer/consumer split across PRs. The two CI PRs (#1529, #1549) touch different workflow files. The two provider PRs (#1543, #1546) are slices of the same parent #1540 but touch different files (Completions converters vs Responses parsers) — independently mergeable; merging both closes #1540.

⚠️ Conflicts detected

None. All file sets are pairwise disjoint.

Notes

@sytone

sytone commented Jun 22, 2026

Copy link
Copy Markdown
Owner

PR Parallelism Map — 2026-06-23

10 open PRs. File-set analysis (live gh pr view --json files) confirms all 10 are pairwise file-disjoint — zero shared files. No issue-level Part of/Depends on/Blocked by edge connects any two open PRs (the cross-PR #NNNN mentions in PR bodies are stacking-order notes, not dependencies). All 10 are MERGEABLE / CLEAN.

✅ Can merge in parallel (independent) — all 10

PR Title Component Linked Issue
#1529 ci(security): guard for security-sensitive boundary files CI / Security Closes #1525
#1530 perf(persistence): bound SQLite session/conversation caches + lock pools Persistence Closes #1504
#1531 refactor(gateway): extract PrepareTurnAsync from ProcessAsync Gateway refs #1503 (issue)
#1533 feat(security): SecurityEvent model + trusted ring-buffer sink Security/Diagnostics Closes #1532
#1534 docs: backfill v0.7.0–v0.8.1 release pages + CLI reference fixes Docs
#1537 fix(cli): align port-availability probe with wildcard bind CLI Closes #1536
#1543 refactor(providers): unify completions converter into Core Providers Closes #1540 (slice 1)
#1544 refactor(gateway): split cross-world routing out of AgentExchangeService Gateway Closes #1542
#1546 refactor(providers): unify Responses stream parsers into Core Providers Closes #1545 (#1540 slice 2)
#1549 ci(workflows): per-ref concurrency groups for CodeQL/CI CI Closes #1548

⏩ Must merge sequentially (dependencies)

None. Disjoint file sets and no open-PR↔open-PR dependency edge.

⚠️ Conflicts detected

None.

Notes

Posted by the daily GitHub-issues grooming job.

@sytone

sytone commented Jun 23, 2026

Copy link
Copy Markdown
Owner

PR Parallelism Map — 2026-06-24

Open PRs: 1 — the merge queue drained hard since yesterday (Jon batch-merged ~17 PRs across two drains: 3d7d3698f8f38a594eb4ae9d, releasing v0.9.0). Only one PR remains open, so there is nothing to sequence and no possible inter-PR conflict.

✅ Can merge in parallel (independent)

PR Title Component Linked Issue Mergeable
#1572 feat(portal): render ask_user prompts on mobile chat Blazor client (Core + Mobile) Closes #1491 ✅ MERGEABLE / CLEAN

⏩ Must merge sequentially (dependencies)

None — single open PR.

⚠️ Conflicts detected

None — single open PR; no shared files with any other open PR.


#1572 detail — touches 6 files, all confined to the SignalR Blazor client:

  • …BlazorClient.Core/Components/AskUserPrompt.razor (git-moved from the desktop project so Core + desktop + mobile share one component; same RootNamespace → desktop refs unchanged, stay green)
  • …BlazorClient.Core/_Imports.razor (new)
  • …BlazorClient.Mobile/Pages/Chat.razor, …Mobile/_Imports.razor, …Mobile/wwwroot/css/mobile.css
  • …BlazorClient.Mobile.Tests/MobileAskUserPromptTests.cs (7 new bUnit tests)

No Program.cs, *.slnx, Directory.Build.props, migration, or domain-model changes. CI green, mergeStateStatus = CLEAN. Ready to merge whenever Jon chooses.

⚠️ Separate platform-health note (not a PR): main CI (CI: Build & Test @ 4eb4ae9d) is currently red on a single flaky bUnit test (CronConversations_VirtualKind_RenderedInScheduledGroup). I re-triggered the failed run and filed the forensic analysis on #1573 — it is an OnAfterRender/localStorage ordering race, not a regression (passes 606/606 locally on the same HEAD; the named "#1571 culprit" is file-disjoint from the Blazor client). Watch #1573 for the re-run result.

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.

2 participants