Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
5fba953
fix(mcpl): reconcile channel lifecycle operations in order
Oct 2, 2026
cd2638d
fix(mcpl): preserve tune-out while settling delivery transport
Oct 2, 2026
d64c803
fix(mcpl): distinguish delivery joins from opening intent
Oct 2, 2026
9c94d0a
fix(mcpl): bound channel lifecycle convergence
Oct 2, 2026
541a339
Merge current main into bounded channel lifecycle fix
Oct 2, 2026
8af5598
docs(changelog): qualify lifecycle convergence at success
Oct 2, 2026
de1a9f3
fix(mcpl): validate open receipts against the current target
Oct 2, 2026
cd726dd
fix(mcpl): preserve pending decisions after target invalidation
Oct 2, 2026
24a84f5
fix(mcpl): distinguish unconfirmed targets from confirmed closure
Oct 2, 2026
8fb4c78
fix(mcpl): a channels/open answered for another channel fails the open
Oct 9, 2026
1997b12
fix(mcpl): undo the open a substituting server made; check history id…
Oct 9, 2026
c46e853
docs(changelog): the substitution check reads history ids, closes the…
Oct 9, 2026
e5cb38a
fix(mcpl): announce a delivery's open only when it turned a closed ch…
Oct 10, 2026
a6130d0
test(mcpl): pin the lifecycle pieces no test reached
Oct 10, 2026
7d859cb
fix(mcpl): speech engages a channel decided closed as a reply does; a…
Oct 10, 2026
fd5fc52
fix(mcpl): speech engages a decided-closed channel only where the hos…
Oct 10, 2026
5633ca2
test(mcpl): state the open decision the in-flight-close test depends on
Oct 10, 2026
f5402db
test(mcpl): pin five more lifecycle pieces the removal run found unte…
Oct 10, 2026
c67d06c
docs(changelog): an announced open is one the channel wasn't already …
Oct 10, 2026
27fdd5b
docs(changelog): state the delivery-notice rule the same way in both …
Oct 10, 2026
9e45ae1
fix(mcpl): a channel's transport is known only from a receipt for its…
Oct 11, 2026
23e28e0
fix(mcpl): a failed RPC for a retargeted channel's old target tries t…
Oct 17, 2026
1f34286
Merge main (3af13bf4, after train fix-a) into fix/185-channel-lifecyc…
Oct 17, 2026
3380fc9
refactor(mcpl): the substitution line prints its ids as the registry'…
Oct 17, 2026
4b60771
docs(mcpl): openChannelNow's doc says where an open is recorded and w…
Oct 17, 2026
c11b559
docs(mcpl): the substitution line's comment and fragment say which li…
Oct 17, 2026
5200d2a
Merge main (07563022) into fix/185-channel-lifecycle-order
Oct 18, 2026
6e18b1a
Merge main 0a7fd161 into fix/185-channel-lifecycle-order: this branch…
Oct 18, 2026
8637d55
test(channels): a draft published into a channel with a close pending…
Oct 18, 2026
206c23a
Merge main 8d7694fb: the disconnect removes channel entries before th…
Oct 18, 2026
94d2ad3
test(channels): a channel registered while a disconnect closes its se…
Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog.d/210-channel-lifecycle-order.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
- Failed backscroll requests preserve the registry's known-open state rather than claiming a close. Automatic delivery joins pending lifecycle work without reopening a channel closed during the wait. A delivery or reply is announced as opening a channel, with the current registration label, only when the channel wasn't already decided open or tuned out; reconfirming the transport of a channel that was isn't announced. Speech engages a channel decided closed as a reply does, even while a failed close has left its transport open, wherever the server's grant lets the host open it. When `channel_open` fails for a channel that isn't open and is still decided open, its error says the channel stays selected for opening, since that decision outlives the failed round-trip and a later delivery or reconnect tries again.

- Channel lifecycle operations now run in order per channel and reconcile the latest desired state and registration after an in-flight operation completes. Successful reconciliation confirms the current target and desired state at its receipt boundary; a superseded tool request reports that its requested state no longer applies. Automatic delivery preserves active tune-out attention state while opening or awaiting transport. A registration batch reconciles its channels side by side, so one held RPC doesn't delay its siblings. Removing a server cancels its queued lifecycle operations, and the framework removes its channel entries before closing its connection, so nothing queued runs against a closing connection or a later re-registration.

- A channel's transport state is known only from a receipt for its current registration, connection, type, and address. A channel a registration creates or replaces, a retarget, a substitution, or a failed open or close leaves it unconfirmed: failed corrective opens fail joined delivery, while a same-target backscroll failure keeps a channel that was open usable. Incoming traffic confirms nothing unconfirmed (after a retarget it may still come from the old subscription), and it marks a channel open only while that channel is decided open or tuned out, so a close receipt stands. Explicit close, and declining an invitation, close an unconfirmed target for real instead of reporting it already closed. A retarget of a channel decided open or tuned out opens the new target.

- Lifecycle convergence makes at most five transport attempts per operation. Continued descriptor/intent supersession fails with a diagnostic, releases the queue, and gives joined speech or reply preparation a visible failure instead of hanging. Later stable operations can retry.

- A `channels/open` answered for a different channel than the one requested, by the answer's `channel.id` or a history item's `channelId`, compared exactly as the server's traffic is, fails the open. The requested channel isn't marked open (its open state becomes unconfirmed), nothing in the answer is used, its history included, and the host logs `[channel-open-substituted]` and emits an `mcpl:channel-open-substituted` trace, whose `answeredFor` is the answered id as the log line prints it, escaped by `logValue`. When the channel the server opened instead is registered and its desired state is closed, the host closes it again, so its traffic doesn't reach the resident. A substituted answer on a receipt already made stale by retargeting is retried like any stale receipt. An answer that names no channel, by a null id or none, is accepted as before; one whose id isn't a string fails the open the same way, with nothing to close. Either rollout order is safe: on a server that still falls back, the resident sees a failed open for an id that server no longer knows (a deleted channel), rather than another channel's traffic. `ChannelsOpenParams.channelId` is documented as exact: a server that doesn't know the id should refuse.
1 change: 1 addition & 0 deletions changelog.d/210-policy-admission-notice.breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `onChannelAutoOpened` with `source: 'subscription-policy'` now reports a durable admission decision before transport reconciliation. Treat it as intent; await the registration call and inspect `getOpenChannels()` when confirmed live state is needed. `opened-by-delivery` reports a delivery that opened a channel not already decided open or tuned out, once its transport is confirmed; a delivery that only reconfirms the transport of a channel that was isn't reported. The built-in host notice now distinguishes selected-for-opening from currently open.
62 changes: 36 additions & 26 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14305,13 +14305,15 @@ export class AgentFramework {
}

/**
* Durable window notice for a channel the agent did not ask to open —
* subscription-policy admission, or an open forced by the agent's own
* delivery into a closed channel. The agent must always learn that the
* channel opened, and that `channel_close` opts out (their decision
* outranks policy). What an open channel delivers is its server's to say
* (#406), so the notice doesn't promise its traffic. System message: never
* triggers inference, never conversational, never locus-eligible.
* Durable window notice for a channel the agent did not ask to open: a
* subscription-policy admission, announced as the decision before its
* transport RPC (a failed open keeps the decision, and channel_list shows
* transport state), or an open that the agent's own delivery or reply
* caused (`opened-by-delivery`, `opened-by-reply`), once its transport is
* confirmed. Either way it names `channel_close` as the opt-out (their
* decision outranks policy). What an open channel delivers is its server's
* to say (#406), so the notice doesn't promise its traffic. System message:
* never triggers inference, never conversational, never locus-eligible.
*/
private recordChannelAutoOpenNotice(
agentName: string | undefined,
Expand All @@ -14324,8 +14326,7 @@ export class AgentFramework {
.join(', ');
const plural = channels.length > 1;
const optOut = ' What an open channel delivers to you depends on its server. ' +
`If you don't want ${plural ? 'one of them' : 'it'}, use channel_close — ` +
'your choice sticks; neither policy nor a later send will silently reopen it.';
`If you don't want ${plural ? 'one of them' : 'it'}, use channel_close; your choice outranks policy.`;
// An open caused by an agent's own send is that agent's to hear about, in
// the window it reads; a policy admission with no agent behind it goes to
// the primary. An agent that has ended (a run whose final reply is routed
Expand All @@ -14342,15 +14343,14 @@ export class AgentFramework {
console.error(`[channel] ${agentName} has ended, and there is no primary to tell that ${shown} opened`);
return;
}
text = `[channels] Now open: ${shown} — opened by ${agentName}'s reply; ${agentName} has ended.` + optOut;
text = `[channels] Now open: ${shown}: opened by ${agentName}'s reply; ${agentName} has ended.` + optOut;
metadata = { system: true, kind: 'channel-notice', speaker: agentName } as MessageMetadata;
told = `the primary, by name (${agentName} has ended)`;
} else {
const causeText =
cause === 'subscription-policy'
? 'your subscription policy just admitted ' + (plural ? 'them' : 'it')
: 'your own message into the closed channel engaged it';
text = `[channels] Now open: ${shown} — ${causeText}.` + optOut;
text = (cause === 'subscription-policy'
? `[channels] Selected for opening by subscription policy: ${shown}. ` +
'This records the admission decision; channel_list shows current transport state.'
: `[channels] Now open: ${shown}: your own message into the closed channel opened it.`) + optOut;
if (agentName) target = { forAgent: agentName };
told = agentName ?? 'primary';
}
Expand Down Expand Up @@ -22706,19 +22706,21 @@ export class AgentFramework {
}

/**
* Disconnect an MCPL server at runtime: close the connection, destroy its
* feature-set and checkpoint state (permanent removal — unlike a transient
* transport close, which preserves checkpoints for the reconnect), remove
* its channels from the registry, drop routing entries, and refresh tools.
* Disconnect an MCPL server at runtime: remove its channels from the
* registry (first, cancelling their queued lifecycle work), close the
* connection, destroy its feature-set and checkpoint state (permanent
* removal — unlike a transient transport close, which preserves checkpoints
* for the reconnect), drop routing entries, and refresh tools.
* No-op-ish if the server is not connected (still clears routing state).
*
* It holds the id throughout (holdMcpServerId): a connect for it is
* refused meanwhile, and a connect already in flight is ended (closed, or
* displaced when its legacy handshake finishes) and settles before
* anything is removed. A close that can't confirm a child exited fails the
* disconnect before anything is removed: the server stays registered,
* closed, so nothing launches beside a child that may still be running,
* and disconnecting again retries the reap.
* anything but the channel entries the disconnect found when it began is
* removed. A close that can't confirm a child exited fails the disconnect
* before anything but those entries is removed: the server stays
* registered, closed, so nothing launches beside a child that may still be
* running, and disconnecting again retries the reap.
*/
async disconnectMcplServer(id: string): Promise<void> {
this.refuseOperatorAfterStop('disconnect MCPL server');
Expand All @@ -22736,6 +22738,9 @@ export class AgentFramework {
const registry = this.mcplServerRegistry;
const hold = this.holdMcpServerId(id, 'disconnect');
try {
// Channel entries go first: removing them cancels the server's queued
// lifecycle work, which must not run against the closing connection.
this.channelRegistry?.removeServer(id);
// Close what the id has now, so a connect in flight ends rather than
// being waited out: a modern connect is ended by its close, a legacy
// one still in its handshake (or a WebSocket dial waiting for its
Expand Down Expand Up @@ -22769,6 +22774,8 @@ export class AgentFramework {
);
}
if (modern && this.modernServers().get(id) === modern) this.modernServers().delete(id);
// Again, for the channels a connect that was still running registered
// after the first removal, and any lifecycle work they queued.
this.channelRegistry?.removeServer(id);

// Permanent removal: also destroy feature-set and checkpoint state
Expand Down Expand Up @@ -24356,9 +24363,11 @@ export class AgentFramework {
// Recorded here — not eagerly — because injection metadata
// carries the CANONICAL channel id (descriptor form), while
// send args often carry the raw provider id; this callback
// has the resolved form. The already-open path resolves on
// has the resolved form. A channel confirmed open resolves on
// the microtask queue, i.e. before the tool-result event
// that evaluates injections.
// that evaluates injections. One decided open whose transport
// is unconfirmed is opened again first, so its engagement is
// recorded when that open returns.
if (status !== 'unknown-channel' && status !== 'ambiguous') {
sendingTurn?.engaged.add(channelId ?? target);
}
Expand All @@ -24373,7 +24382,8 @@ export class AgentFramework {
);
} else if (status === 'open-failed') {
console.error(
`[channel] ${agentName}: ${toolName} delivered to ${target} but the open-on-send failed — channel remains half-alive, will reconcile`,
`[channel] ${agentName}: ${toolName} delivered to ${target} but the open-on-send failed; ` +
'a later registration or delivery tries the open again',
);
}
})
Expand Down
Loading
Loading