Skip to content
Open
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
2 changes: 1 addition & 1 deletion ably-common
Submodule ably-common updated 291 files
2 changes: 1 addition & 1 deletion docs/internals/client-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ All non-lifecycle messages pass through the codec decoder inside `applyWireMessa
1. `decoder.decode(rawMessage)` yields `{ inputs, outputs }` split by wire direction.
2. `tree.applyMessage({ inputs, outputs }, headers, serial)` — the Tree folds events into the owning Run's (or input node's) projection and emits an `output` event carrying the message's outputs. This is the single fan-out point for run outputs; consumers (the View, and the Vercel chat transport's per-run stream) subscribe to it. A wire-only carrier that decodes to no events and carries no `run-id` is skipped (the eventual reply run is created later by its run-start).

After the apply returns, `_handleMessage` calls `tree.emitAblyMessage(rawMsg)` so subscribers to `'ably-message'` can observe the raw wire — emitted _after_ the apply so View subscribers can already find the owning Run. Any error thrown while processing a message is caught and surfaced as a session `error` event (`SessionSubscriptionError`) rather than escaping the listener.
After the apply returns, `_handleMessage` calls `tree.emitAblyMessage(rawMsg)` so subscribers to `'ably-message'` can observe the raw wire — emitted _after_ the apply so View subscribers can already find the owning Run. Any error thrown while processing a message is caught and surfaced as a session `error` event (`SessionMessageProcessingFailed`) rather than escaping the listener.

There is no separate observer-state map. The Tree's per-Run projection is the single source of truth for every Run (own or observer); the View extracts messages on demand via `codec.getMessages(run.projection)`.

Expand Down
43 changes: 23 additions & 20 deletions docs/reference/error-codes.md

Large diffs are not rendered by default.

33 changes: 26 additions & 7 deletions scripts/validate-error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,19 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ERRORS_JSON_PATH = path.join(__dirname, '../ably-common/protocol/errors.json');

/** One registry entry, as generated into errors.json from `errors/codes/*.md`. */
interface ErrorEntry {
/** The registry's canonical snake_case name for the code. */
identifier: string;
/** Short human-readable title. */
title: string;
/** One-paragraph description of the failure. */
summary: string;
}

interface ErrorsJson {
[code: string]: string;
/** Registry entries keyed by numeric code. */
codes: Record<string, ErrorEntry | undefined>;
}

function main(): void {
Expand All @@ -29,19 +40,27 @@ function main(): void {
process.exit(1);
}

const registry = errorsJson.codes;
// A pin predating the `codes` envelope yields no registry at all — say so,
// rather than reporting every code as missing.
if (typeof registry !== 'object') {
console.error(`No "codes" object in ${ERRORS_JSON_PATH}; the ably-common submodule may be stale or uninitialised`);
process.exit(1);
}

// Get all error codes from the enum
const errorCodes = Object.values(ErrorCode).filter((value) => typeof value === 'number') as number[];

console.log(`Validating ${errorCodes.length} error codes from ErrorCode enum...\n`);

let hasErrors = false;
const missingCodes: number[] = [];
const foundCodes: Array<{ code: number; message: string }> = [];
const foundCodes: Array<{ code: number; identifier: string }> = [];

for (const code of errorCodes) {
const codeStr = code.toString();
if (errorsJson[codeStr]) {
foundCodes.push({ code, message: errorsJson[codeStr] });
const entry = registry[code.toString()];
if (entry) {
foundCodes.push({ code, identifier: entry.identifier });
} else {
missingCodes.push(code);
hasErrors = true;
Expand All @@ -51,8 +70,8 @@ function main(): void {
// Print results
if (foundCodes.length > 0) {
console.log('Found codes:');
for (const { code, message } of foundCodes) {
console.log(` ${code}: ${message}`);
for (const { code, identifier } of foundCodes) {
console.log(` ${code}: ${identifier}`);
}
console.log();
}
Expand Down
29 changes: 9 additions & 20 deletions src/core/transport/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { locateInputEvent } from './input-event-locator.js';
import { evictOldestIfFull } from './internal/bounded-map.js';
import type { Invocation } from './invocation.js';
import { createLeafBranchSource } from './leaf-branch-source.js';
import { publishLifecycleEvent } from './lifecycle-publish.js';
import { createMaterialisation } from './materialisation.js';
import type { RunManager } from './run-manager.js';
import { createRunManager } from './run-manager.js';
Expand Down Expand Up @@ -403,7 +404,7 @@ class DefaultAgentSession<
'error',
new Ably.ErrorInfo(
`unable to end run ${reg.runId} on session end; ${errorMessage(error)}`,
ErrorCode.RunLifecycleError,
ErrorCode.RunLifecycleEventPublishFailed,
500,
errorCause(error),
),
Expand Down Expand Up @@ -565,7 +566,7 @@ class DefaultAgentSession<
} catch (error) {
const errInfo = new Ably.ErrorInfo(
`unable to process cancel for run ${runId}; onCancel handler threw: ${errorMessage(error)}`,
ErrorCode.CancelListenerError,
ErrorCode.RunCancelHandlerFailed,
500,
errorCause(error),
);
Expand Down Expand Up @@ -676,7 +677,7 @@ class DefaultAgentSession<
this._handleCancelMessage(msg).catch((error: unknown) => {
const errInfo = new Ably.ErrorInfo(
`unable to route cancel message; ${errorMessage(error)}`,
ErrorCode.CancelListenerError,
ErrorCode.RunCancelRoutingFailed,
500,
errorCause(error),
);
Expand Down Expand Up @@ -882,7 +883,7 @@ class DefaultAgentSession<
} catch (error) {
const errInfo = new Ably.ErrorInfo(
`unable to notify steer for run ${runId}; onSteer handler threw: ${errorMessage(error)}`,
ErrorCode.CancelListenerError,
ErrorCode.RunSteerHandlerFailed,
500,
errorCause(error),
);
Expand Down Expand Up @@ -1146,10 +1147,9 @@ class DefaultAgentSession<
}

/**
* Run a run-lifecycle publish (run-start / run-suspend / run-end) and wrap
* any failure as a `RunLifecycleError`, logging at error and rethrowing.
* Shared by start(), suspend(), and end() so the three publishes can't
* drift on the error code, message shape, or cause preservation.
* Run a run-lifecycle publish (run-start / run-suspend / run-end) through
* the shared lifecycle bracket, which the step-lifecycle publishes in
* {@link createRunStepWriter} also use.
* @param phase - The lifecycle wire phase, used in the error message.
* @param method - The Run method name, used in the log prefix.
* @param publish - The RunManager publish to run.
Expand All @@ -1159,18 +1159,7 @@ class DefaultAgentSession<
method: 'start' | 'suspend' | 'end',
publish: () => Promise<void>,
): Promise<void> => {
try {
await publish();
} catch (error) {
const errInfo = new Ably.ErrorInfo(
`unable to publish ${phase} for run ${runId}; ${errorMessage(error)}`,
ErrorCode.RunLifecycleError,
500,
errorCause(error),
);
logger?.error(`Run.${method}(); failed to publish ${phase}`, { runId });
throw errInfo;
}
await publishLifecycleEvent({ phase, method, runId, logger }, publish);
};

// The shared run read-model (runId, status, error, whole-turn messages).
Expand Down
58 changes: 58 additions & 0 deletions src/core/transport/lifecycle-publish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import * as Ably from 'ably';

import { ErrorCode } from '../../errors.js';
import type { Logger } from '../../logger.js';
import { errorCause, errorMessage } from '../../utils.js';

/**
* A lifecycle wire event whose publish is bracketed by
* {@link publishLifecycleEvent}. Spans both tiers — the run's own lifecycle and
* the step lifecycle nested within it — because a failed step publish is the
* same class of failure as a failed run publish and surfaces identically.
*/
export type LifecyclePhase = 'run-start' | 'run-suspend' | 'run-end' | 'step-start' | 'step-end';

/**
* Options identifying the lifecycle publish being bracketed.
*/
export interface PublishLifecycleOptions {
/** The lifecycle wire phase, named in the error message. */
phase: LifecyclePhase;
/** The method name to prefix the error log with (e.g. `start`, `openStep`). */
method: string;
/** The run the event belongs to, named in the error message. */
runId: string;
/** Logger for the failure; the phase and `runId` are logged with it. */
logger?: Logger;
/** Extra structured context for the failure log (e.g. the step id). */
logContext?: Record<string, string>;
}

/**
* Run a lifecycle publish and wrap any failure as a
* {@link ErrorCode.RunLifecycleEventPublishFailed}, logging at error and
* rethrowing. Every run- and step-lifecycle publish goes through here so they
* cannot drift on the error code, message shape, or cause preservation.
* @param options - Identifies the publish (see {@link PublishLifecycleOptions}).
* @param publish - The RunManager publish to run.
* @returns Whatever `publish` resolves with (the ACK serial, for the publishes that report one).
* @throws {@link Ably.ErrorInfo} with {@link ErrorCode.RunLifecycleEventPublishFailed} if `publish` rejects.
*/
export const publishLifecycleEvent = async <T>(
options: PublishLifecycleOptions,
publish: () => Promise<T>,
): Promise<T> => {
const { phase, method, runId, logger, logContext } = options;
try {
return await publish();
} catch (error) {
const errInfo = new Ably.ErrorInfo(
`unable to publish ${phase} for run ${runId}; ${errorMessage(error)}`,
ErrorCode.RunLifecycleEventPublishFailed,
500,
errorCause(error),
);
logger?.error(`Run.${method}(); failed to publish ${phase}`, { runId, ...logContext });
throw errInfo;
}
};
11 changes: 9 additions & 2 deletions src/core/transport/run-step-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { Logger } from '../../logger.js';
import { errorCause } from '../../utils.js';
import type { Codec, CodecInputEvent, CodecOutputEvent } from '../codec/types.js';
import { buildTransportHeaders } from './headers.js';
import { publishLifecycleEvent } from './lifecycle-publish.js';
import { pipeStream } from './pipe-stream.js';
import type { RunManager, StepClientScopes } from './run-manager.js';
import type { DefaultTree } from './tree.js';
Expand Down Expand Up @@ -307,7 +308,10 @@ export const createRunStepWriter = <
// inference runs. markOutputProduced fires per-pass in doPipe/doSend instead.
// The steers to stamp are likewise drained per-pipe, not here.
const scopes = stepScopes(stepClientId);
const stepStartSerial = await runManager.startStep(runId, stepId, scopes);
const stepStartSerial = await publishLifecycleEvent(
{ phase: 'step-start', method: 'openStep', runId, logger, logContext: { stepId } },
async () => runManager.startStep(runId, stepId, scopes),
);
getTree().applyStepLifecycle({
type: 'step-start',
runId,
Expand Down Expand Up @@ -352,7 +356,10 @@ export const createRunStepWriter = <
return;
}
const scopes = stepScopes(stepClientId);
await runManager.endStep(runId, stepId, stepStartSerial, reason, scopes);
await publishLifecycleEvent(
{ phase: 'step-end', method: 'closeStep', runId, logger, logContext: { stepId } },
async () => runManager.endStep(runId, stepId, stepStartSerial, reason, scopes),
);
getTree().applyStepLifecycle({
type: 'step-end',
runId,
Expand Down
8 changes: 5 additions & 3 deletions src/core/transport/session-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,17 @@ export const subscribeAndAttach = async (

/**
* Wrap a failure thrown while processing an inbound channel message as a
* `SessionSubscriptionError`, preserving the original as `cause`. Single source
* of truth for the message-processing error shape both sessions surface.
* `SessionMessageProcessingFailed`, preserving the original as `cause`. Single source
* of truth for the message-processing error shape both sessions surface. Kept
* distinct from the connect-time `SessionSubscriptionError`: the subscription
* survives this, so the session stays usable and the fix is in the handler.
* @param error - The thrown value.
* @returns The wrapped error.
*/
export const wrapMessageProcessingError = (error: unknown): Ably.ErrorInfo =>
new Ably.ErrorInfo(
`unable to process channel message; ${errorMessage(error)}`,
ErrorCode.SessionSubscriptionError,
ErrorCode.SessionMessageProcessingFailed,
500,
errorCause(error),
);
Expand Down
16 changes: 11 additions & 5 deletions src/core/transport/types/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,11 @@ export interface RunHooks<TOutput extends CodecOutputEvent> {
* - Stream failures in `pipe` — the underlying error is also returned on
* `StreamResult.error`, but this callback delivers it wrapped as an
* `Ably.ErrorInfo` (code `StreamError`) for standardized observability.
* - Failures in the `onCancel` handler.
* - A throw from the `onCancel` handler (code `RunCancelHandlerFailed`). The run
* is NOT cancelled: the SDK never reaches the abort.
* - A throw from the `onSteer` handler (code `RunSteerHandlerFailed`). The run is
* unaffected — the steering message has already folded in, so only the
* notification failed.
*
* Publish failures in `start` and `end`
* are not delivered here — those methods reject their returned promise
Expand All @@ -265,9 +269,9 @@ export interface RunHooks<TOutput extends CodecOutputEvent> {
*
* Channel-wide events (e.g. continuity loss) are delivered via the
* session-level {@link AgentSession.on}('error'), not here. A failure in the
* `onCancel` handler with no `onError` set falls back to that session emitter
* so it is never silently dropped; a `pipe` stream failure with no `onError`
* is always still available on {@link StreamResult.error}.
* `onCancel` or `onSteer` handler with no `onError` set falls back to that
* session emitter so it is never silently dropped; a `pipe` stream failure
* with no `onError` is always still available on {@link StreamResult.error}.
*/
onError?: (error: Ably.ErrorInfo) => void;

Expand Down Expand Up @@ -316,6 +320,7 @@ export interface RunStep<TOutput extends CodecOutputEvent> {
* a second call is a no-op. Rejects if another step is already active on the
* run (only one step may be open at a time), or if the run has ended.
* @throws InvalidArgument if another step is active or the run has ended.
* @throws {Ably.ErrorInfo} `RunLifecycleEventPublishFailed` if the `ai-step-start` publish fails.
*/
start(): Promise<void>;
/**
Expand Down Expand Up @@ -359,6 +364,7 @@ export interface RunStep<TOutput extends CodecOutputEvent> {
* auto-closes a still-open step, so a forgotten `end()` cannot strand
* observers — but an explicit `end()` is clearer and lets you set the reason.
* @param params - Optional {@link StepEndParams}; the reason is derived if omitted.
* @throws {Ably.ErrorInfo} `RunLifecycleEventPublishFailed` if the `ai-step-end` publish fails.
*/
end(params?: StepEndParams): Promise<void>;
}
Expand Down Expand Up @@ -587,7 +593,7 @@ export interface OpenableRun<TOutput extends CodecOutputEvent, TProjection, TMes
* no-op. Propagates `located`'s rejection (cancel / session close).
* @throws {Ably.ErrorInfo} `OperationCancelled` when the run was cancelled
* before `start()` (or `located` rejected on cancel); `SessionClosed` when
* the session closed; `RunLifecycleError` when the opening publish fails.
* the session closed; `RunLifecycleEventPublishFailed` when the opening publish fails.
*/
start(): Promise<void>;
}
Expand Down
43 changes: 35 additions & 8 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,27 @@ export enum ErrorCode {
EncoderRecoveryFailed = 104000,

/**
* The session's channel subscription failed — the subscribe/attach step
* failed, or a session-level subscription callback threw unexpectedly.
* The session could not subscribe to and attach its channel during
* `connect()`. Nothing sends or receives until the attach succeeds; whether a
* retry helps depends on the `cause` (a transient disconnect clears, a
* capability or auth rejection does not).
*/
SessionSubscriptionError = 104001,

/**
* A run-scoped developer callback threw while the SDK invoked it — the
* `onCancel` hook processing a cancel message, or the `onSteer` hook
* notifying that a steering message folded into the run.
* The run's `onCancel` hook threw while the SDK was processing a cancel
* message. The SDK never reaches the abort, so the run is **not** cancelled.
*/
CancelListenerError = 104002,
RunCancelHandlerFailed = 104002,

/**
* A publish within a run failed (lifecycle event, message, or event).
* A lifecycle event publish failed, at either tier: a run's `ai-run-start` /
* `ai-run-suspend` / `ai-run-end`, or a step's `ai-step-start` /
* `ai-step-end`. The event is not on the channel, so clients do not observe
* the run or step entering that phase. The underlying publish failure is the
* `cause`.
*/
RunLifecycleError = 104003,
RunLifecycleEventPublishFailed = 104003,

/**
* An operation was attempted on a session, view, or encoder that has already
Expand Down Expand Up @@ -95,6 +100,14 @@ export enum ErrorCode {
*/
StreamError = 104008,

/**
* Processing an inbound channel message threw — the codec folding it into
* session state, or a session-level subscription callback. The subscription
* survives and the session keeps sending and receiving; only that one
* message's processing failed. The thrown value is the `cause`.
*/
SessionMessageProcessingFailed = 104009,

/**
* A fresh process adopting an open run via {@link AdoptedRun.load} waited for
* that run's `ai-run-start` to be observed on the channel — across the live
Expand All @@ -113,6 +126,20 @@ export enum ErrorCode {
* `cause` where available.
*/
HistoryFetchFailed = 104011,

/**
* The run's `onSteer` hook threw while the SDK was notifying it that a
* steering message folded into the run. The steering message has already
* folded in by then, so the run is unaffected — only the notification failed.
*/
RunSteerHandlerFailed = 104012,

/**
* Routing an inbound cancel message to its target run failed. Not a fault in
* a developer-supplied hook: the dispatch itself could not complete, so the
* cancel was neither honoured nor rejected and the run keeps running.
*/
RunCancelRoutingFailed = 104013,
}

/**
Expand Down
Loading
Loading