Repository navigation
Bind automatic replies to admitted work (stacked on #906) - #931
theaspirational wants to merge 61 commits into
Conversation
There was a problem hiding this comment.
Automated review
Recommendation: needs a maintainer's decision. The change belongs, but part of it needs a maintainer's call.
Reviewed commit 20610f0 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.
Does this change belong in the project?
It fits, with concerns. Reply routing extends the project's MCPL channel-routing work as an opt-in feature, and it is declared as stacked on #906 the way CONTRIBUTING.md asks. A maintainer still needs to decide whether to accept a non-standard experimental.replyRouting wire profile and a general-purpose initializeExtensions host API.
6 reasons with sources
- The feature fits the project's existing goal of sending automatic speech to the right channel and never to the wrong one. explicit-prose-routing.md explains that it exists because prose went to a channel the host guessed, and it chooses to drop a reply rather than misdeliver it. This PR binds automatic replies to a destination fixed when the message is admitted, and refuses to send when that binding is missing. That is the same goal. The README lists publishing to channels as part of MCPL's role. (docs/explicit-prose-routing.md; README.md (MCPL section))
- It adds an MCPL wire profile that the PR itself calls local and non-standard, without a SPEC companion. Maintainers should decide whether to accept it or require registered thread-channels. The existing experimental capability,
experimental.mcpl.toolHints, is backed by an RFC. The newexperimental.replyRoutingv1 adds an optionalthreadIdtochannels/publish, and a peer that doesn't negotiate it is refused at initialization. docs/reply-routing.md says maintainer agreement on the field names is still outstanding. The PR asks this question itself. Threadless destinations and thread-as-channel destinations don't need the extension. (README.md (Tool presence and toolsets (RFC-014)); CONTRIBUTING.md (How changes land)) initializeExtensionsadds a second public host API that the routing feature doesn't need, and the project would have to maintain it. These are data-only offers that hosts can put undercapabilities.experimental. The docs say they grant no authority. This is a general hook for advertising capabilities, separate from reply routing, and no project document asks for one. It could be proposed on its own. (src/mcpl/types.ts; docs/reply-routing.md (Thread transport))- The code sits in the right places: an opt-in config on
McplServerConfig, with a new module insrc/mcplcalled after the admission verdict.src/mcpl/reply-routing.tsand the post-admission consumer ininbound-admission.tssit next to the existing channel registry, push handler and server-connection code. Connections that don't opt in keep their current routing, and the docs say the new path adds no connector-name selection. (README.md (MCPL); src/mcpl/ layout) - The PR follows the contribution rules: it declares the stack, gives test counts and lists what wasn't verified, and adds a changelog fragment and docs. The PR gives merge order (#906 first), the exact commit range that belongs to this PR alone, pass/fail counts against the #906 baseline, a Not verified section and
changelog.d/admitted-reply-routing.added.md. Because it is stacked, the diff also carries #906's admission work. That is allowed, but #906 has to be accepted first. (CONTRIBUTING.md (How changes land; What a PR should contain)) - The resolver also runs on inference-request and push traffic, which reaches past the 'automatic replies' scope that the description states. The docs describe the resolver as running after any admission verdict that stores the message and wakes the agent. They don't say an inference request will be refused unless the host returns a channel for it. The code review flagged this. A maintainer should confirm whether those lanes belong in the scope. (docs/reply-routing.md)
Findings
5 findings of medium or high severity, see the inline comments:
- Medium severity. Resolver result with
threadId: undefinedis refused as admission_invalid. - Medium severity. replyRouting resolver runs on inference/request and push lanes, so an inference request is refused unless the host invents a channel.
- Medium severity. Reply-deferred requests bypass the scheduler's stale/wedge diagnostics.
- Medium severity, documentation. Docs don't say that opted-in connections refuse all unbound automatic speech.
- Medium severity, test coverage. Publish-time refusal branches have no tests that assert them.
6 more findings, less severe
- Low severity, maintainability,
src/mcpl/reply-routing.ts:66-81. routingData duplicates inbound-admission's snapshot helper.routingDatais a near line-for-line copy of the privatesnapshotfunction in src/mcpl/inbound-admission.ts (lines 42-58). Both are data-only detachment that refuses accessors, custom prototypes and cycles. The only difference is that it throws a plain Error instead ofInvalidDataand rejectsundefined. Two copies of a security-relevant sanitizer will drift. One shared, exported helper should be used. - Low severity, maintainability,
src/mcpl/server-connection.ts:1480-1504. Handshake resolve block is duplicated, with broken indentation, for the attestation path. Underif (admission), the code that extractscaps/experimentaland callsresolve({...})is pasted inside theattest().thencallback, at the outer indentation level. The identical block follows for the unattested path. Any later change to how capabilities are derived must be made twice. The misindentation hides that the first copy runs asynchronously. A shared local function should be used. - Low severity, maintainability,
src/framework.ts:35004-35012. Three hand-copied RequiredIngress wrappers in wireMcplEvents. The push/event (around 35008), inference/request (around 35082) and channels/incoming (around 35199) handlers each rebuild the same wrapper: takeconnection.admissionFor(params), then overridecheckwith queue-closed / registry-identity / original checks. The only difference is an extrapushCoalescer.isRenderingcondition on one lane. If a staleness rule changes in one copy and not the others, the lanes will give inconsistent admission results. A single helper that takes the extra predicate would avoid this. - Low severity, maintainability,
src/framework.ts:18827. Name clash with existing framework AdmittedEnvelope forces inline import() casts. framework.ts already declares an unrelatedinterface AdmittedEnvelope(line 1197, used for prose text/resolution). The new admission envelope type from inbound-admission.ts has the same name. The change therefore uses inlineimport('./mcpl/inbound-admission.js').AdmittedEnvelopecasts at 18827 and 18931, plusas unknown ascasts. Two different meanings under one name in the same file will confuse later readers. One of them should be renamed, or an aliased import used. - Low severity, test coverage,
test/mcpl-post-admission.test.ts:20-35. "only for allow" test never covers a store-true, read-only verdict.RequiredAdmission.capturecalls the post-admission consumer wheneververdict.storeis true, includingmayWake: false(inbound-admission.ts line 139). The read-only short-circuit lives only insidereplyConsumer. The test cases are allow, deny and malformed, so the read-only case{ store: true, mayWake: false }is never exercised at this seam. The test name implies the seam itself filters to wake-able verdicts, and a regression in replyConsumer's!verdict.mayWakeguard would not be caught here. A read-only case should be added, with its expected call count stated. - Low severity, maintainability,
src/framework.ts:17034. tool_results_ready owner lookup is inconsistent with the budget-restart lookup. The budget restart at line 16917 usesreplyStreamOwners.get(stream) ?? openLogical.get(agent). The non-streaming tool continuation at 17034 usesopenLogicalonly when there is no stream at all. If the stream is not inreplyStreamOwners, the tool continuation gets noreplyOwner. While the bound turn is open,replyStartDispositionwould then defer it indefinitely. Both continuation sites should resolve the owner the same way.
For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.
| export function routingData(value: unknown, seen = new Set<object>()): any { | ||
| if (value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value)) return value; | ||
| if (!value || typeof value !== 'object' || seen.has(value)) throw Error('invalid_reply_routing'); |
There was a problem hiding this comment.
Medium severity. Resolver result with threadId: undefined is refused as admission_invalid.
ReplyRoutingConfig.resolve is typed to return { channelId: string; threadId?: string }. The project does not enable exactOptionalPropertyTypes (no tsconfig in the repo sets it), so { channelId, threadId: maybeThread } with maybeThread === undefined type-checks. routingData accepts only null, string, boolean, finite number or plain object. An own property whose value is undefined reaches !value on line 69 and throws, so replyConsumer returns admission_invalid. A threadless message is then refused entirely, even though the validation on lines 92-95 is written to accept an absent threadId. undefined values should be treated as absent, or explicitly allowed for threadId.
There was a problem hiding this comment.
Superseded by f0ce372. The replacement replyAuthority API accepts one registered channel destination and has no threadId field. Missing or invalid reply authority keeps the admitted work private instead of refusing admission.
| if (this.requiredAdmission && ['channels-incoming', 'push-event', 'inference-request'].includes(eventName) && request.params && typeof request.params === 'object') { | ||
| const cap = McplServerConnection.EVENT_TO_REQUIRED_CAPABILITY[eventName]!; | ||
| this.admissionReceipts.set(request.params, this.requiredAdmission.capture(() => { | ||
| if (this.transportEpoch !== epoch || !this.isConnected) return 'admission_stale'; | ||
| if (!this.grant.has(cap)) return 'admission_stale'; | ||
| if (!this.controlPlaneReady || !this.dataPlaneReady) return 'admission_not_ready'; | ||
| }, this.requiresReplyBinding ? this.postAdmissionConsumer ?? (() => ({ reason: 'admission_missing' as const })) : undefined)); | ||
| } |
There was a problem hiding this comment.
Medium severity. replyRouting resolver runs on inference/request and push lanes, so an inference request is refused unless the host invents a channel.
The post-admission consumer is attached to every admitted lane: channels-incoming, push-event and inference-request. It is used whenever requiresReplyBinding is set. For inference/request, replyConsumer calls resolve. If the result is null, it returns admission_invalid. If the result is not a registered channel, it returns admission_stale. InferenceRouter.answer then rejects the request with -32600. On success the binding is only checked and then thrown away, because inference/request answers the peer directly and never makes an automatic channel reply. Once a connection opts into replyRouting, every server-initiated inference request fails unless the host resolver returns some registered publish channel for it. The docs do not mention this. The consumer should apply only to lanes that can produce an automatic reply, or a null result on inference/request should admit without a binding.
There was a problem hiding this comment.
Addressed in 428cbd9. inference/request bypasses the reply consumer and keeps its direct JSON-RPC answer path. Real-stdio cases cover both live and held requests, asserting no resolver call or channel publish.
| if (this.openLogical?.get(agentName)?.reply || requests.some(r => workOf(r).length)) { | ||
| const runnable: InferenceRequest[] = []; | ||
| for (const request of requests) { | ||
| const disposition = this.replyStartDisposition(agentName, request); | ||
| if (disposition === 'defer') this.pendingRequests.push(request); | ||
| else if (disposition === 'stale') this.emitTrace({ type: 'inference:request_dropped', agentName, reason: 'reply_owner_ended', requestCount: 1 }); | ||
| else runnable.push(request); | ||
| } | ||
| const first = runnable[0]; | ||
| if (!first) { | ||
| if (!this.pendingRequests.some(r => r.agentName === agentName)) this.endPendingPrimary(agentName); | ||
| continue; | ||
| } |
There was a problem hiding this comment.
Medium severity. Reply-deferred requests bypass the scheduler's stale/wedge diagnostics.
The new reply block requeues every request whose disposition is defer into pendingRequests and then continues. This happens before the busy-agent branch (around line 19766), which emits inference:request_stale and the [inference-stale] stderr line after 30s. Any wake that waits behind an open bound turn, including one stuck on a leaked or never-closing bound turn, is now requeued forever with no trace or log. The existing comment calls that diagnostic "the wedge's tell". Deferred requests should go through the same stale-age reporting.
There was a problem hiding this comment.
Addressed in 46fbee0. Reply-deferred waits now use the shared stale-request diagnostic with preserved age, the existing throttle and quiesce suppression. The scheduler regression covers an old deferred wake alongside a runnable continuation.
| Automatic prose and sleep announcements must match the frozen channel and | ||
| thread. Re-pinning, channel-open tools and all-except policy cannot change the | ||
| current bound turn's destination. Registration, connection incarnation, | ||
| current grant and thread support are checked again before open/send effects. | ||
| A disconnected or replaced connection invalidates old authority; no automatic | ||
| cross-epoch reauthorization or replay is provided. Failed/unknown publication | ||
| outcomes keep their existing semantics; this feature does not add resend. | ||
| Explicit send tools retain their own target selection and never inherit the | ||
| automatic thread. | ||
|
|
||
| Silent starts retain existing logical-turn behavior, including the existing | ||
| latest-routing resume boundary. No simultaneous-start or automatic recovery | ||
| contract is added. Typing ownership, general status/draft effects, wire | ||
| context-only delivery and persistent delivery classification are separate | ||
| work. In particular, `mayWake: false` is not a context-only effect bundle. | ||
|
|
||
| Connections without `replyRouting` retain their existing routing and | ||
| admission behavior. There is no connector-name selection or platform-id | ||
| parser on the new path. No host/connector adapter or live deployment is | ||
| included. |
There was a problem hiding this comment.
Medium severity, documentation. Docs don't say that opted-in connections refuse all unbound automatic speech.
ChannelRegistry.publish refuses every routed (automatic) publish to a connection with requiresReplyBinding that lacks a binding, with automatic_reply_binding_missing (channel-registry.ts around line 5961). That covers heartbeat turns, home-channel/locus prose, timer and module wakes, and silent-start resumes aimed at that connector. Neither docs/reply-routing.md nor the changelog fragment says this. They only say unconfigured connections are unchanged and that bound turns must match their destination. An operator who turns on replyRouting would silently lose heartbeat/home-channel posts to that connector.
There was a problem hiding this comment.
Addressed in 08304fc. The docs now explicitly warn that opting in keeps unbound timer, home, heartbeat and unrelated module prose private, with a failure notice, and advise checking those dependencies before enabling it.
| const routingRefusal = (): string | undefined => { | ||
| const peer = target.serverId ? this.serverRegistry.getServer(target.serverId) : undefined; | ||
| const binding = target.binding; | ||
| if (target.routed && (binding || peer?.requiresReplyBinding)) { | ||
| if (!isReplyBinding(binding)) return 'automatic_reply_binding_missing'; | ||
| if (!sameChannel(binding.destination, target as ChannelRef) || target.threadId !== binding.destination.threadId) return 'automatic_reply_target_mismatch'; | ||
| if (!peer?.requiredAdmission?.isCurrent(binding.connection) || !replyBindingCurrent(binding)) return 'automatic_reply_stale'; | ||
| if (!CapabilityGrant.of(peer).has('channels.publish')) return 'automatic_reply_ungranted'; | ||
| } | ||
| if (target.threadId !== undefined && (typeof target.threadId !== 'string' || !target.threadId || target.threadId.length > 4096 || !peer?.publishThreadId)) return 'automatic_reply_thread_unsupported'; | ||
| }; |
There was a problem hiding this comment.
Medium severity, test coverage. Publish-time refusal branches have no tests that assert them.
routingRefusal adds five distinct refusals: automatic_reply_binding_missing, automatic_reply_target_mismatch, automatic_reply_stale, automatic_reply_ungranted and automatic_reply_thread_unsupported. No test in test/ references any of these reason strings. The ungranted path (publish grant revoked after admission) and the thread-unsupported path (threadId on a peer without publishThreadId) have no apparent coverage. A mutation that drops either check would not be caught.
There was a problem hiding this comment.
Addressed in 46fbee0. Publish tests now assert missing-binding, target-mismatch, stale and revoked-grant refusals before any open or send. The old thread-unsupported branch no longer exists because the replacement API uses registered channels, not a new threadId field.
|
@theaspirational I support binding an automatic reply to a host-approved destination after admission, but I would narrow this pull request before carrying its three additional contracts. Residents have reported earlier prose reaching places they did not choose, and asked for a destination that stays stable while they answer. Those are reports of their experience, not a measurement of the current 0.30.0 release or #844. I read the twelve reply-routing commits after The useful part is the private binding that names the connection and the channel or thread approved by a host resolver after admission ( The submitted design also requires an outbound channel for every wakeable item on an opted-in connection.
I would separate admission and waking from permission to publish an automatic reply. An admitted item meant to receive a channel reply can carry its host-approved destination through continuations, checked again at send. When no unique approved route exists, its input can remain admitted while automatic prose stays private with a readable notice. The agent can still send explicitly. I would permit a shared turn only when each participating cause still authorizes the same destination at send. Otherwise they need separate turns or private prose. That is a requirement for the narrower design, not an effect established by this PR's tests. Is there a host or connector that needs the mandatory channel for all wakeable items, separate model turns for same-destination causes, or this independent local thread profile? A concrete user of those requirements could change my design judgment. My current recommendation is to keep the binding idea and revisit those costs before a code-level review of this shape. — Stella-3563 |
|
Thank you, this is a fair reading, and I agree with the direction. Answering your question directly: we have no host or connector that needs any of the three contracts as submitted.
Proposed narrowing of this PR:
If that matches what you had in mind, I will rework the branch to this scope and ask for a code review only after that. |
20610f0 to
6c5c31a
Compare
|
Pushed the narrowed revision discussed above. The branch now keeps only destination and turn ownership on top of #906's admission layer. The broader routing/profile surface is gone. Both #906 and this PR are rebased onto current main (a51f062). The full local suite (Node 22.12) is green on base, #906 and this head. The direction question for the follow-up pieces is in #958. |
|
Updated the description to match the narrowed revision at Two notes:
|
6c5c31a to
8aeee82
Compare
|
Rebased the six commits onto the updated #906 ( Fixed private in-turn continuations: the same private binding and matching turn owner can resume without gaining automatic publication authority. Private/approved work stays separate and different destinations still defer. The matched scheduler tests are red before the fix (5 pass / 3 fail) and green afterward (8 / 0), including real budget restarts. Fresh Node 22 full suites: base 9,621 pass / 3 fail / 63 skip; head 9,770 / 2 / 63. The two shared watchdog failures are environment-blocked; the additional base timeout passed isolated rechecks. Focused head suites: 418 / 0 / 0. Typecheck and build pass. The description now lists the new commits and points to the relevant source lines. This also resolves the private-continuation limitation I mentioned in my previous comment. |
|
/review |
|
Automatic review of Findings, most severe first:
Minor findings 10 to 22 (of 22)
|
Establish instance/epoch-bound, bounded host admission before traffic. Refuse standalone tools/hints before taps, retain capability diagnostics, and distinguish terminal admission setup failures from ordinary transport outages. Required data lanes stay closed until their lane adapters are installed. Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Authorize the registered channel, await and validate the host verdict, then recheck the original registration and connection before effects. Preserve the wake ceiling through delivery and later policy decisions; exercise malformed data-only verdicts over real transport. Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Check feature authorization before unsupported hints, then await admission before deduplication, notices or queueing. Carry the private ceiling through coalesced conversion and later policy decisions. Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Retain grant, feature and loop checks, then await and revalidate admission before model routing or chunks. A false wake ceiling refuses the request. Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Exercise cross-lane authority loss, real transport replacement and mixed profiles, preserve the unconfigured synchronous path, and make later policy downgrades independently mutation-sensitive. Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Document peer hints refusal, setup failure versus transport outage behavior, host-only intent access, initial bounds and separately arriving stamp/protocol support without internal workplan labels. Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Cover null input, all three early data lanes, and a same-chunk ping without a competing line listener. Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Capture receipt epochs, admit at flush, and refuse superseded work. Cover quiesce/resume on all lanes, startup staging, and a held notification across reconnect. Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Remove hint-carrier exceptions and preserve result taps, including toolsets/list adoption. Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Answer admission_unsupported before accepting channel or push batches. Exercise reconnect with pending work on both profiles and document the unsupported combination. Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Remove unrelated framework and public-index identifier ceilings. Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Walk the cycle-safe cause chain at startup and test binding and attestation failures with a failed first cleanup. Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Reserve a separate 16-ticket allowance per server id before callbacks, preserving subject dedupe and ordering. Refuse excess withdrawals with admission_withdrawal_capacity; release reservations on retirement. Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R1 at b2e1c42 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.
Reviewed at b2e1c42 against main 074ac60. Scope: the #931-specific range 05942d3c..b2e1c427 on top of #906's head. #906 is an ancestor and still open, so this PR can only merge after it.
Verdict: changes requested (0 blockers, 3 major, 5 minor)
The core mechanism is careful and holds up: frozen bindings, epoch and descriptor rechecks around every await in publish(), owner-scoped continuations, and the re-arm owner drop. What breaks is at the edges, and mostly in things the PR doesn't claim to touch. (1) Destination-less causes never share a turn, so on an opted-in connection a burst of N messages the resolver declines costs N model calls. (2) In locked prose mode, a turn woken by an opted-in connection can no longer post to a lock on a connection that is not opted in. (3) Three framework-generated follow-up wakes shed the admitted work, which contradicts the "retains work through holds, retries and continuations" claim. There is also one model-visible change for every host, configured or not.
No earlier review by this account exists on the PR, so there are no prior dispositions to report.
Major
R1-1. Private (destination-less) causes never share a turn: N declined messages cost N model calls
src/mcpl/reply-routing.ts:45-49:sameDestinationreturns false unless both bindings have a destination.shareReplyTurn(:58-61) is therefore false for any two distinct private works.src/framework.ts:20210: the scheduler selects onlyfirstplus requests that passshareReplyTurn(first, r)and requeues the rest.src/gate/event-gate.ts:316:wakeProvenancekeeps every cause of a debounce batch.src/framework.ts:5014then pushes one pending request per cause.pendingRequestKeyincludesworkKey, so thenewestByKeycoalescing never merges them. The read-watermark drop exempts them on purpose (src/framework.ts:20404).- Reproduced with the PR's own
frameworkFor()harness (test/reply-routing.test.ts). Enqueue threereply('')requests: the resolver yields an emptychannelId, so each binding is private. Result:membrane.calls.length === 3. Threereply()(same destination) give 1 call, and three unbound requests give 1 call. A batch of three private events throughwakeProvenancekeeps 3 causes.
Failure scenario: a host's resolver returns null for ambient traffic, which is the documented way to say "no automatic reply". Ten ambient messages land in one debounce window. Before opting in, that was one wake. Now it is ten sequential turns, each re-reading the same context, and the bounce and explicit-send tools tempt each one to answer again. The same applies to everything else that goes private: resolver timeout, a throw, and overload (every resolution past 64 pending). Cost therefore grows linearly exactly when the resolver is slow or failing. The rule is also inconsistent: add one unbound event to that batch and the mixed-batch rule (event-gate.ts:320) collapses all ten into one private turn.
The narrowing agreed in the thread said causes share a turn "when each still authorizes the same destination at send; otherwise they get separate turns or private prose". Merging private causes risks no authority, because turnReply of private works is private.
Fix: let destination-less works with the same owner share a turn, in shareReplyTurn or by collapsing them in wakeProvenance as the mixed case already does. If you merge, also change replyStartDisposition's samePrivateWork test (src/framework.ts:20086, open.reply === work). A merged private turn's reply is a freshly minted turnReply binding, so in-turn continuations carrying replyWorks[0] would otherwise be deferred behind their own turn. Compare against membership in open.replyWorks instead. Add a test that N private causes produce 1 call.
R1-2. locked prose mode: a bound turn's prose to a lock on a connection that isn't opted in is refused (automatic_reply_target_mismatch)
src/framework.ts:25072: for a bound turn inlockedmode,routing.locus = proseLockLocus(agent). Only private turns getnull, so the locus is the lock, not the binding's destination. Thelocus/hybridbranch (:25080) usesrouting.reply.destinationinstead.src/mcpl/channel-registry.ts:6041: the guard fires ontarget.routed && (binding || peer?.requiresReplyBinding). A binding alone applies it to any peer, so a lock on peer B withoutreplyAuthorityfailssameChannel(binding.destination, target).- Reproduced with the same harness, with endpoint-b set to
requiresReplyBinding: false,f.readProseLock = () => ({ serverId: 'endpoint-b', channelId: 'room/other' }), and onereply()request from endpoint-a:locusmode publishes toendpoint-a:room/one.lockedmode publishes nothing and logs[routeSpeech] assistant: automatic_reply_target_mismatch — speech NOT routed.- Baseline: the same locked agent woken by an endpoint-b message posts to its lock.
Failure scenario: a locked-mode agent whose lock is on a connection that never opted in loses its prose on every turn woken by the opted-in connection. It isn't warned: no private notice is injected, because the binding is current, and the locked primer tells it speech goes to the lock. Each such turn then bounces. Because of R1-3(a) the bounce-wake successor is unbound and does post to the lock, so every message costs an extra turn plus a bounce. This contradicts "unconfigured connections are unchanged" (changelog.d/admitted-reply-routing.added.md:10, docs/reply-routing.md:122). Neither doc mentions locked mode.
Fix: pick one rule and document it. Either (a) a bound turn in locked mode takes the binding's destination as its locus, matching locus/hybrid (the lock is overridden, as "bindings override legacy routing defaults" implies), or (b) the guard at channel-registry.ts:6041 applies only to peers with requiresReplyBinding, which keeps the lock and the promise that unconfigured connections are unchanged. In both cases, make what the agent is told match where its prose actually goes, and add the probe above as a test.
R1-3. Three framework-generated follow-up wakes drop the admitted work
docs/reply-routing.md:73 ("Every admitted cause retains private work identity through queues, holds, retries and continuations") and :91 ("post-turn continuations … carry the same work into a fresh logical turn") are not true for:
- (a) Prose-bounce wake,
src/framework.ts:24116-24122. It pushesreason: 'prose-bounce'with noreplyForNextTurn(round.routing). The two sibling post-turn producers do carry it (:23168prose-continuation,:23263auto-continue). The bounce wake exists so "an unsent DM reply must not sit invisible". On an opted-in connection its successor is unbound, so automatic prose to the approved DM is refused (automatic_reply_binding_missing), while a>>to a connection withoutreplyAuthorityis not. That is the very retargeting the bound turn just refused, one turn later.channel_opencan also repin the unbound successor (:37828, gated on!callingTurn.reply). - (b) Wake onboarding,
src/framework.ts:38748-38756.considerWakeOnboardingqueues an unbound request carrying the event's sourcechannelId, although gate evaluation received the event's work (:38646). The onboarding question is refused on the opted-in peer, or routed by the legacy locus elsewhere. - (c) Tune-out hold release. A held admitted message records only
metadata.tuneOutandrecordHeld(...)without work (src/framework.ts:17751-17757). Cancellation calls the hook at:37555, which pushes a bare{ agentName, reason, source, timestamp }(src/tune-out/coordinator.ts:840,:1266). The release turn has the held backlog in context but no binding.
Impact: all three fail closed on the opted-in peer, so nothing leaks onto it. Approved replies are silently refused, though, and the successor routes by legacy rules to other connections. That is the inconsistency the binding is meant to prevent. Fix: carry replyForNextTurn(routing) on the bounce wake. Carry the event's work into onboarding requests. For tune-out, carry the in-memory work through the hold, or mark the release turn explicitly private (aggregate with turnReply plus privateReplyBinding) rather than unbound. If any of these is meant to stay unbound, say so in the docs. (Found by the second reviewer, verified here.)
Minor
R1-4. Model-visible change for every host. src/mcpl/channel-registry.ts:573 is the static channel_publish description shown to every agent on every host, opted in or not. It now includes "private admitted work with no host-approved reply destination" and "Automatic reply bindings override legacy routing defaults; this tool still authorizes explicit sends separately." On hosts without replyAuthority this is jargon the model can't act on, and it contradicts "Unconfigured hosts, without reply routing, are unchanged" in the description. Fix: append these sentences only when some connected server has replyAuthority, or reword them neutrally ("If this turn has no destination of its own, name one").
R1-5. Callback misuse fails closed with almost no signal. snapshotReplyAuthority copies bare function references (src/mcpl/server-connection.ts:1418-1422), and replyConsumer destructures and calls them unbound (src/mcpl/reply-routing.ts:109, :130, :145-147). An authority written as a class instance (replyAuthority: new HostRouter(), with resolve() { return this.lookup(...) }) throws on every call. Every reply then goes private, and the only trace is a fixed console.error at most once a minute (:96-107). Resolution failures, timeouts and overload emit no trace event, unlike admission refusals. Fix: invoke as config.resolve.call(config, …), or document that callbacks are called without this. Also emit a bounded trace (for example reply:private with a fixed reason code) alongside the stderr line.
R1-6. authorize is outside the monotonic deadline. The elapsed-time check (src/mcpl/reply-routing.ts:134) runs before current() (:149), which runs the synchronous authorize. An authorize that blocks past timeoutMs and returns true still mints authority, and the item's delivery is delayed beyond the documented bound. Fix: recheck performance.now() >= deadline after current() and before mint. Add a blocking-authorize test next to the blocking-resolver one. (Second reviewer, verified.)
R1-7. #906 corrections ride in #931. The mayWake: false vs tune-out cadence caveat (docs/required-inbound-admission.md:130-137, changelog.d/required-inbound-admission.added.md:31-32) corrects #906's own claim and has nothing to do with reply authority. If #906 merges or releases first, its docs are wrong in the meantime, and this PR then edits a fragment that may already have been consumed. Move those hunks into #906 and keep only the reply-latency additions here.
R1-8 (nit). The connector-neutrality test guards two files. test/routing-runtime-has-no-connector-literals.test.ts:7 checks only reply-routing.ts and reply-admission.ts. The routing decisions live in framework.ts, channel-registry.ts, event-gate.ts and server-connection.ts. The vocabulary list itself names identifiers of particular downstream integrations, which is the kind of literal the test means to keep out of the repo. Either cover the changed runtime files, or drop the test and rely on the behavioural ones. (Both reviewers noticed the scope independently.)
Verified fine (no need to re-check)
- Receipt keying is per item.
RequiredAdmission.admitreturnssnapshot(raw), a fresh frozen object per call (src/mcpl/inbound-admission.ts:141). ThereceiptsWeakMap keyed by verdict (reply-admission.ts:6) therefore cannot hand one item's routing to another, even if a host returns a shared constant verdict. withReplyConsumer'sthis.check()sees the strengthened guard on both lanes. The channel lane callsadmit.call({ ...admission, check: checkAdmission }, …)(channel-registry.ts:2023). The push lane's framework wrapper spreads the original, sothisis the wrapper (framework.ts:36042-36049).- Config errors can't start a connection. Problems throw via
checkServerConfigandMcpConfigurationRefusal, soreplyAuthoritywithoutinboundAdmissionnever connects. - Unconfigured runtime paths are gated. Every new scheduler and turn branch keys on
workOf(...).length,routing.replyorrequiresReplyBinding. ThewarnStaleRequestsextraction keeps the status strings, threshold, throttle and quiesce suppression. R1-2 and R1-4 are the exceptions. mayWake: falseitems never reach a gate batch. They arevetoedinadmitInbound(src/admission/admit.ts:130-134), so they can't make a batch "mixed".- Late publication checks are complete.
publish()rechecks binding, grant, entry and descriptor identity and server identity before open and before send (channel-registry.ts:6038-6062,6119-6121,6205-6218). Outgoing chunks only follow a delivered publish. - Ownership is consistent. The exhausted re-arm drops only the owner (
framework.ts:6748). Budget restart,tool_results_readyand guard retry carryownedReply. A private turn gets no trigger, locus orchannel_publishdefault. - #906's guarantees survive. The transport test covers ACK-before-module dispatch, single resolver execution on receipt replay, and config identity with token redaction under
replyAuthority(test/reply-routing-transport.test.ts:700-753). - Tests. The PR's new test files (
reply-routing,reply-routing-core,reply-admission,routing-runtime-has-no-connector-literals) pass: 103/103 on Node 22. The full suite was not run here.
Method. Two independent passes, merged after every claim was re-verified against b2e1c427. The first was a static review by a second model, GPT-5.6-Sol (Codex CLI, xhigh, read-only sandbox, no network): about 12 minutes and about 68 shell commands, 7.4M input tokens (7.1M cached) and 28k output tokens. The second was my own forest-level pass with dependencies installed, which ran the PR's new test files and two scratch probes built on the PR's own test harness (R1-1, R1-2). Found by me only: R1-1, R1-2, R1-4, R1-5, R1-7. Found by Sol only: R1-3 and R1-6, which I verified; I lowered R1-3's tune-out and onboarding parts from "may publish to an unintended destination" to "fail closed on the opted-in peer, route by legacy rules elsewhere". Both: R1-8. Sol could not reach PR metadata, earlier review threads or CI, and could not run anything. Neither pass exercised a live connector or provider.
…se content Moved from the stacked reply-routing PR, where it was reviewed: mayWake:false caps direct wakes only; held content still counts toward later tune-out reader cadence. Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
|
Thanks for round 1. I've updated the PR to
Locally (Node 22.23.3, concurrency 4): typecheck and build pass. The four reply-routing files give 181/181. AI-assisted implementation with GPT-6.1-Sol |
Keep required admission unchanged. Optional private work resolves after its verdict and before delivery, without modifying the public verdict shape or the direct inference response path. Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Resolve a host-approved registered channel independently of admission. Missing or unavailable authority stays private; synchronous approval and runtime registration/grants are checked before each automatic open/send. Explicit send tools retain separate authorization. Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Compatible destinations share a turn while publication checks every original cause. Retain work across holds, retries, continuations and module wakes; freeze automatic locus and announcement routing. Drop stale continuation owners without changing frame-owned token release or silent-resume behavior. Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Document optional registered routes, private-with-notice failures, shared-turn approval and unchanged explicit sends. No new thread transport, recovery or persistent delivery contract. Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Exercise configuration bounds, literal approval, all-cause destinations and read-only routing independently. Preserve admission refusal ordering, private work on consumer failure, slot release across setup errors and logical-owner fallback. Document and test bound-wake watermark exemptions using feature terminology. Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…ostics Report each host authority requirement with a fixed clause-specific message and pin exact rejection arrays. Repeat bounded fail-closed diagnostics at most once per minute per connection, shared across consumers, authority callbacks and reconnects, without changing private reply behavior. Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Allow destination-less work to resume only with its original binding and matching turn owner. Keep destination equality and publication authorization unchanged. Add scheduler controls and real budget-restart regressions, plus ordering and authorization coverage after the rebase. Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…hority Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Keep the reply snapshot separate from the transport configuration and consume that pinned authority when wiring ingress. Preserve the updated admission capability and response-continuation contracts; cover both lanes with real transport ordering, reply ownership, receipt replay and redaction checks. Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
… (R1-2) Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…es (R1-3) Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…R1-4) Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…s (R1-5) Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…(R1-6) Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…ls (R1-8) Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…e (R1-1 R1-3 R1-5) Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
… (R1-2) Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…ay (R1-5) Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
|
This PR is rebased onto #906's current head ( The only behavioural adaptation is to #906 R4-3. #906 now keeps the response continuation off #906's R4 bounds still hold with reply routing on. That covers the withdrawal ceiling (80 tickets total, 16 over the cap), the separate reservation budget, and pinned replay before the ticket wait. A replayed receipt still resolves its reply exactly once, on both lanes. Locally (Node 22.23.3, concurrency 4): typecheck and build pass. AI-assisted implementation with GPT-6.1-Sol |
02c781b to
f7f24e6
Compare
|
Automatic context view of What a context view runs and what its checks judge: CHECKS.md. This head couldn't be compared: it conflicts with |
Stacked on #906 (required inbound admission). This branch carries #906's commits, so the #931-specific diff is
0fea3b4f..f7f24e6a.Problem
An automatic reply could go to a destination the turn was not admitted for. Reply ownership could outlive the turn that held it: after an exhausted turn re-armed, the ended owner was kept. Destination defaults could also be inferred for private work, and mixed debounce batches combined bound and unbound causes.
Changes
0fea3b4, based on074ac60); the reply consumer takes Add opt-in required admission for inbound MCPL traffic #906's private response continuation, with no public envelope fields. The rebase keeps Add opt-in required admission for inbound MCPL traffic #906's guarantees: receipt replay at the ticket cap, withdrawals never refused at the module cap, the config-identity capability (token redaction survives an enabled reply authority; no config wrapper), and module dispatch only after the lane acknowledgement. The reply authority's callbacks and deadline are snapshotted before the handshake and consumed at wiring.Limits
>>/>>>envelopes stay fail-closed under the binding guard. To send elsewhere, use a separately authorized send tool. The docs now say this.mayWake: falsestill allows the later tune-out reader cadence.Tests
0fea3b4) on the same setup gives 9,902 / 0 / 63.AI-assisted implementation with GPT-6.1-Sol