Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 28 additions & 0 deletions changelog.d/channel-notices-reach-who-can-close.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
- The notice that a channel opened without the agent asking (an open its
own send or reply caused, or a subscription-policy admission) names
`channel_close` only to an agent that can call it there for every channel
it names, except where several residents share one slot (#291), and in
forks spawned from the primary afterwards (#235). A conversation fork can
close only its home channel, and an agent's `allowedTools` can leave
`channel_close` out. Before, such an agent
was told to use `channel_close`, and couldn't call it, or its call was
refused. Where an agent's own send opened a channel it can't close, the
primary is now told by name too, if it can close it, and the agent is told
that it was.
- The opened notice says "What an open channel delivers to you depends on
its server" only to an agent whose window holds the channel's traffic.
Before, a conversation fork was told this about a channel other than its
home, whose messages never reach it.
- The primary's notices name a conversation fork by the channel it's bound
to, then by its generated name. The notice of an open by an agent that has
ended says "a message from" it, where it said "opened by X's reply" whether
a reply or speech had opened the channel. The reply notice and speech's
failure notice say why a channel is selected for opening again with
"because", where they said "as".
- When a conversation fork or an ephemeral run is released without reading a
channel notice it could act on (a channel its own send opened, or a
channel its reply couldn't open that is selected for opening), the primary
is told by name in its place, as for a reply whose delivery failed, of what
still holds then: a channel closed since is left out, and so is one no
longer selected for opening, or open since. Before, the notice stayed
unread in the released agent's window.
803 changes: 582 additions & 221 deletions src/framework.ts

Large diffs are not rendered by default.

74 changes: 55 additions & 19 deletions src/mcpl/channel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,43 @@ interface ChannelLifecycleEvent {
acknowledgment?: string;
}

/** A lifecycle-log item as a desired-state decision, or null when it isn't
* one this build reads: the one rule the registry's replay
* (initializeLifecycleStore) and desiredStateInLog read the log by. */
function desiredStateRecord(item: unknown): {
serverId: string; channelId: string; state: DesiredChannelState; source: string; tuneOut?: TuneOutParams;
} | null {
const event = item as Partial<ChannelLifecycleEvent> | null | undefined;
if (event?.kind !== 'desired-state' || typeof event.serverId !== 'string' || typeof event.channelId !== 'string') return null;
if (!(event.desired === 'open' || event.desired === 'closed' || (event.desired === 'tuned-out' && event.tuneOut))) return null;
return {
serverId: event.serverId,
channelId: event.channelId,
state: event.desired,
source: typeof event.source === 'string' ? event.source : 'unknown',
...(event.desired === 'tuned-out' ? { tuneOut: event.tuneOut } : {}),
};
}

/**
* The desired state `store`'s lifecycle log records for `channelId` on
* `serverId`, for a reader with no registry yet, as a serving boot's
* deferred-write flush is, before initializeMcpl: the last decision wins, as
* the registry's replay reads it (getDesiredState answers once there is a
* registry). undefined when no decision names it.
*/
export function desiredStateInLog(store: JsStore, serverId: string, channelId: string): DesiredChannelState | undefined {
ensureStateRegistered(store, { id: CHANNEL_LIFECYCLE_LOG_ID, strategy: 'append_log' });
const raw = store.getStateJson(CHANNEL_LIFECYCLE_LOG_ID);
if (!Array.isArray(raw)) return undefined;
let state: DesiredChannelState | undefined;
for (const item of raw) {
const decision = desiredStateRecord(item);
if (decision?.serverId === serverId && decision.channelId === channelId) state = decision.state;
}
return state;
}

/** One append-log record in `CHANNEL_LABEL_HISTORY_LOG_ID`: "at `ts`, this
* channelId's label was observed to be `label`". See `labelHistory`. */
interface ChannelLabelSightingEvent {
Expand Down Expand Up @@ -2056,8 +2093,15 @@ export class ChannelRegistry {
return typeof label === 'string' && label.length > 0 ? label : undefined;
}

isChannelOpen(channelId: string): boolean {
return this.findChannelEntry(channelId)?.open === true;
/** Whether `channelId`'s transport is open: on `serverId` where the caller
* knows the channel's server, and otherwise on the first server found to
* register that id (findChannelEntry). The map's key joins the two ids
* with a colon, so another server's entry can sit under a key: it has to
* be this server's (as in streamingServerAt). */
isChannelOpen(channelId: string, serverId?: string): boolean {
if (serverId === undefined) return this.findChannelEntry(channelId)?.open === true;
const entry = this.channels.get(`${serverId}:${channelId}`);
return entry?.serverId === serverId && entry.open === true;
}

getDesiredState(serverId: string, channelId: string): DesiredChannelState | undefined {
Expand Down Expand Up @@ -2292,20 +2336,11 @@ export class ChannelRegistry {
if (!item || typeof item !== 'object') continue;
const event = item as Partial<ChannelLifecycleEvent>;
if (typeof event.serverId !== 'string') continue;
if (
event.kind === 'desired-state' &&
typeof event.channelId === 'string' &&
(event.desired === 'open' || event.desired === 'closed' ||
(event.desired === 'tuned-out' && event.tuneOut))
) {
const decision = desiredStateRecord(item);
if (decision) {
this.desiredStates.set(
this.lifecycleKey(event.serverId, event.channelId),
{
state: event.desired,
source: typeof event.source === "string" ? event.source : "unknown",
tuneOut: event.desired === 'tuned-out' ? event.tuneOut : undefined,
wakeCount: 0,
},
this.lifecycleKey(decision.serverId, decision.channelId),
{ state: decision.state, source: decision.source, tuneOut: decision.tuneOut, wakeCount: 0 },
);
} else if (
event.kind === 'tune-out-wake' &&
Expand Down Expand Up @@ -3529,7 +3564,7 @@ export class ChannelRegistry {
// The caller reports both: the channel isn't open, but it stays
// selected for opening, so reconciliation may open it later.
throw new ChannelLifecycleError(
afterClause(lifecycleClause(err), 'the channel stays selected for opening, as this host couldn\'t record ' +
afterClause(lifecycleClause(err), 'the channel stays selected for opening, because this host couldn\'t record ' +
`undoing that: ${requestErrorText(putBackErr)}`),
'put-back-unrecorded',
undefined,
Expand Down Expand Up @@ -3576,8 +3611,8 @@ export class ChannelRegistry {
}
if (selectedAgain) {
throw new ChannelLifecycleError(
afterClause(lifecycleClause(err), 'the channel is selected for opening again, as ' +
(selectedAgain === 'server-bootstrap' ? 'its server announces it open' : 'the subscription policy admits it')),
afterClause(lifecycleClause(err), 'the channel is selected for opening again, because ' +
(selectedAgain === 'server-bootstrap' ? 'its server lists it as open by default' : 'the subscription policy admits it')),
'selected-again',
undefined,
{ by: selectedAgain, cause: err },
Expand Down Expand Up @@ -4657,7 +4692,8 @@ export class ChannelRegistry {
* after an open for a declaration leave the channel open, and the agent
* gets the failure for the words. An open by delivery (`openSource`
* 'opened-by-delivery', the default) that turned a closed channel open
* also gives the notice that it's open, which says how to close it.
* also gives the notice that it's open, which says how to close it to a
* reader that can.
* One that only reconfirmed the transport of a channel already decided
* open or tuned out changes no decision, and gives none. When the
* registration is gone, the failure says the channel was removed.
Expand Down
Loading
Loading