Consolidate binding-actor orchestration into shared channel engines - #2005
Merged
Aaronontheweb merged 7 commits intoAug 19, 2026
Merged
Conversation
| if (_pendingCursorTs is not { } pending || ts.CompareTo(pending) > 0) | ||
| _pendingCursorTs = ts; | ||
| } | ||
| _outputEngine.AdvancePendingCursorForEnqueuedTurn(currentTs?.Value); |
| isAuthorizedSender: senderId => | ||
| SlackAclPolicy.IsAllowedUser(new SlackUserId(senderId), _dependencies.Options), | ||
| log: _log, | ||
| readCursor: () => _cursorTs?.Value, |
Comment on lines
+75
to
+76
| Assert.Equal(0, SnowflakeCursorComparer.Instance.Compare( | ||
| "1234567890123456789", "1234567890123456789")); |
| { | ||
| Assert.Equal(0, SnowflakeCursorComparer.Instance.Compare( | ||
| "1234567890123456789", "1234567890123456789")); | ||
| Assert.Equal(0, SnowflakeCursorComparer.Instance.Compare(null, null)); |
Comment on lines
+172
to
+176
| catch (Exception ex) | ||
| { | ||
| _log.Error(ex, "Failed to route {Channel} text approval response for call {CallId}", _channelName, pending.CallId); | ||
| return true; | ||
| } |
Comment on lines
+277
to
+282
| catch (Exception ex) | ||
| { | ||
| _log.Error(ex, "Failed to route {Channel} approval response for call {CallId}", _channelName, callId); | ||
| respondSynchronously?.Invoke(CommandNack.For(_sessionId, ApprovalNackReasons.PersistFailed)); | ||
| return; | ||
| } |
Comment on lines
+421
to
+429
| catch (Exception ex) | ||
| { | ||
| _log.Error( | ||
| ex, | ||
| "Failed to route cold {Channel} text approval response from sender {SenderId}", | ||
| _channelName, | ||
| senderId); | ||
| return false; | ||
| } |
Comment on lines
+61
to
+68
| catch (Exception ex) | ||
| { | ||
| var duration = ElapsedMs(startedAt); | ||
| logFailure(ex); | ||
| ChannelTelemetry.For(_channelType).RecordReplyFailed(duration); | ||
| await _notifyDeliveryFailedAsync(DeliveryFailureKind.TransportFailure, ex.Message); | ||
| return false; | ||
| } |
Comment on lines
+133
to
+137
| catch (Exception ex) | ||
| { | ||
| _log.Warning(ex, "Thread history fetch failed for session {SessionId}", _sessionId.Value); | ||
| return; | ||
| } |
Comment on lines
+304
to
+310
| catch (Exception ex) | ||
| { | ||
| // Non-fatal: execute the turn without an adopted window and keep | ||
| // hydration re-armed so a later authorized inbound retries. | ||
| _log.Warning(ex, "Re-armed thread history fetch failed for session {SessionId}", _sessionId.Value); | ||
| return baseInput; | ||
| } |
Aaronontheweb
force-pushed
the
refactor/binding-engine
branch
from
August 19, 2026 02:08
cd51d35 to
4ba9688
Compare
Aaronontheweb
marked this pull request as ready for review
August 19, 2026 02:09
Aaronontheweb
force-pushed
the
refactor/binding-engine
branch
from
August 19, 2026 02:51
4ba9688 to
7108661
Compare
Aaronontheweb
force-pushed
the
refactor/binding-engine
branch
from
August 19, 2026 03:39
7108661 to
4ba9688
Compare
Plan the extraction of the four duplicated orchestration regions from the Slack, Discord, and Mattermost binding actors into shared engines. The design records the key decision set: plain engine classes over a base actor class, an injected cursor comparator with a length-then- ordinal Discord comparator, hook-limited channel differences, and a stop rule for any real semantic difference found during transplant.
The persisted CursorAdvanced event stores a string cursor for every channel. Discord alone converted that string to ulong for in-memory comparisons, which kept its hydration code textually different from Mattermost's and blocked a shared gap-hydration engine. Hold the cursor as a canonical string and compare with SnowflakeCursorComparer (length first, then ordinal). Plain ordinal is wrong across a digit-length boundary; a unit test proves the comparator matches ulong ordering, with an explicit case that shows the plain ordinal failure. The ulong parse remains as a normalization step, so corrupt IDs are rejected exactly as before and persisted values are byte-identical.
Move the fetch, cursor-filter, injection-classify, adopted-context merge, and turn-enqueue algorithm from the three binding actors into one ThreadGapHydrationEngine in Netclaw.Channels. All constructor dependencies are required. The engine holds no actor state: cursor reads, queue access, warnings, and enqueue bookkeeping are callbacks the actor supplies. Channel differences that remain are genuine and stay per channel: - cursor ordering (Slack decimal event ts, Discord snowflake comparator, Mattermost ordinal) via an injected comparer - authorization basis (Slack ACL policy vs allowed-user options) via a required callback - fetcher availability: Discord and Mattermost construct the engine only when the gateway supplies a history fetcher Slack log lines gain the session and allowed-count fields the other channels already log. No behavior change otherwise; the PR #733 cursor-advance invariants move into the engine verbatim. Net: -859 lines across the three actors, +442 shared.
Move text-approval parsing, the cold-spawn approval path, and prompt resolution from the three binding actors into ApprovalResponseFlow in Netclaw.Channels. All dependencies are required. Persistence stays in the actor via a persist callback. The requester identity check keeps one home in PendingApprovalLookup. The transplant surfaced one real semantic difference: Slack resolved the earliest matching pending approval, Discord and Mattermost the most recent. The shared lookup takes a required ApprovalMatchOrder, so each channel keeps its exact selection. The parity spec documents the difference; which order is correct is a separate product question. Mattermost keeps its synchronous webhook reply through an optional per-call hook; Discord and Slack pass none. One deliberate Slack alignment: its old try block also swallowed journal persist and redraw failures on the text path. The shared flow wraps only the feedback send, so a persist failure now faults the Slack actor exactly as it does the other two. Net: -737 lines across the three actors, +437 shared.
ChannelOutputEngine owns the per-turn delivery state machine: pending cursor, turn-in-flight, reminder observer settlement, empty-turn fallback suppression, and prompt clearing. The engine returns the PendingApprovalPromptCleared events; the actor persists them. A required channel hook handles outputs only some channels support. SafeTransportCall owns the timing, telemetry, failure-notify skeleton for Discord and Mattermost posts and uploads. Slack keeps its own transport wrappers: its three-way exception classification and RecordReplyRejected category do not fit the shared skeleton, per the stop rule. Genuine differences stay per-channel behind hooks: delivery-failure report timing (Slack defers to turn completion), reinitialize cursor discard (Slack only), reminder observed-at source, text trimming, and error formatting. One behavior-preserving unification: prompt-post failure now removes the pending entry before the auto-deny on all three channels, which also removes a Mattermost double-deny risk. Net: -401 lines across the actors, +489 shared.
Aaronontheweb
force-pushed
the
refactor/binding-engine
branch
from
August 19, 2026 12:26
4ba9688 to
8c34a93
Compare
Aaronontheweb
added a commit
that referenced
this pull request
Aug 20, 2026
* Sync delta specs for completed OpenSpec changes Apply the delta specs of 16 completed changes to the main specs. Create four new capability specs: daemon-shell-path, shell-policy-evaluator-architecture, skillserver-native-sidecar-sync, and named-model-definitions. Correct three reminder requirements against the merged code: - One-shot success removes the definition and its history. Only a poisoned one-shot is soft-deleted (PR #1821). - No execution capacity cap exists (PR #1839). The nack and ack-skip policy now covers a duplicate active occurrence and a short acknowledgement lease. - Every delivery kind now holds its envelope. ReminderDeliveryResult replaces ReminderDeliveryObserved. * Archive 16 completed OpenSpec changes Move each completed change to openspec/changes/archive/2026-08-19-<name>/. Their code is merged on dev. Also tick task 5.3 of consolidate-binding-actor-engines. PR #2005 merged that work. Leave eval-run checkboxes unticked. An evals-only gap does not block the archive.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Phase 4 of the code-reduction stack (on top of #2004), implementing OpenSpec change
consolidate-binding-actor-engines(proposal, design, parity spec, and tasks are in the PR). The three channel binding actors — previously ~78% line-identical between Discord and Mattermost — now delegate their four big duplicated regions to shared engines inNetclaw.Channels:SnowflakeCursorComparer(length-then-ordinal; proof test vsulongordering incl. cross-digit-length pairs)ThreadGapHydrationEngine— fetch → cursor-filter → injection-classify → adopted-context merge → enqueueApprovalResponseFlow— text parsing, cold-spawn path, prompt resolution; requester check has one homeChannelOutputEngine(turn-completion state machine) +SafeTransportCallActor sizes: Slack 1,919→1,263, Discord 1,733→1,057, Mattermost 1,684→989. This PR net across src: −346 (engines carry ~150 lines of hook XML docs); across the whole stack the src net is −1,004 with functionality unchanged and coverage strengthened.
Zero behavior change, with three disclosed exceptions
session=/allowed=fields the other channels already log (no test asserts these).Real differences found and preserved (the stop rule working)
ApprovalMatchOrderon the shared lookup; documented in the parity spec. Which is correct is an open product question.TurnCompletedcan commit an abandoned turn's cursor. Possible latent defect in the Discord/Mattermost behavior; preserved as-is and documented in the parity spec.RecordReplyRejecteddon't fit the shared skeleton), delivery-failure report timing (deferred vs inline), reminder observed-at source, text trimming, and error formatting — all per-channel hooks.Verification
Netclaw.Actors.Tests: 3,447 passed / 0 failed / 1 pre-existing Windows-only skip — run after every channel's delegation in every group, plus a 5× stress run at the end. One intermediate run showed 3 unreproduced failures (names not captured; 5 consecutive clean runs after); flagged here for CI scrutiny given the repo's known racy-test history.Netclaw.Daemon.Tests: 1,023 passed / 0 failed. FileFlow suites green.CursorAdvancedvalues byte-identical (sameulong.ToString()output), journal event types unchanged, no config/schema change.dotnet slopwatch analyze: 0 issues; header verification passes; zero build warnings.Stack
PR 4 of 4 in the current stack. Base:
refactor/delivery-failure-drift(#2004).