kimi-code/packages/agent-core-v2/docs/errors.md
Haozhe 8b9916c308
feat(minidb): switch agent-core-v2 query-store to ClusterDb with 16 shards (#1907)
- ClusterDb: add query() (per-shard fan-out with skip=0 and
  limit=skip+limit, global re-sort, then skip/limit) and compound index
  management (create/drop/list through the cluster registry, fanned out
  to every shard and caught up on shard open)
- MiniDbQueryStore: replace the single MiniDb with a 16-shard ClusterDb,
  so multiple kimi processes share the read model instead of failing
  with storage.locked; per-shard LockError now propagates as a transient
  error rather than permanently disabling the read model
- corruption: lift openOrRebuild's predicate (SyntaxError /
  CorruptFrameError) to one process-lifetime wipe-and-reopen rebuild
- lower lockAcquireTimeoutMs to 1s for the cache read model
- tests: cluster query merge and compound fan-out; store coexistence
  with a peer instance, corrupt-registry rebuild, 16-shard topology;
  sessionIndex locked-fallback test now drives a stub IQueryStore
2026-07-19 16:04:26 +08:00

8.3 KiB

errors

Error infrastructure for agent-core-v2: base classes, the per-domain code contract, the public ErrorCodes facade, wire serialization, and the conventions domains follow when raising errors.

Base classes and serialization are centralized in _base/errors; error codes are decentralized — each domain owns an errors.ts that contributes its codes and metadata, and the src/errors.ts facade aggregates them into the unified ErrorCodes const.

Where things live

  • src/_base/errors/errors.ts: base classes — Error2, ExpectedError, ErrorNoTelemetry, BugIndicatingError, NotImplementedError, plus the isError2 guard and unwrapErrorCause.
  • src/_base/errors/codes.ts: the ErrorDomain contract, the ErrorCode type (aliased to the protocol's KimiErrorCode), the runtime registry (registerErrorDomain / errorInfo / isErrorCode), and the domain-independent CoreErrors (internal, not_implemented).
  • src/_base/errors/serialize.ts: ErrorPayload, isCodedError, toErrorPayload, fromErrorPayload, makeErrorPayload. Reads retryability from the registry via errorInfo. The wire-facing names (KimiErrorPayload, toKimiErrorPayload) mirror the protocol contract and keep their names even though the in-process class is Error2.
  • src/_base/errors/errorMessage.ts: toErrorMessage(error, verbose?) for logs/CLI.
  • src/_base/errors/unexpectedError.ts: onUnexpectedError / setUnexpectedErrorHandler / safelyCallListener.
  • src/<domain>/errors.ts: each domain's XxxErrors descriptor (codes + retryable list + per-code info overrides), self-registered on import.
  • src/errors.ts: the facade — imports every domain's errors.ts (triggering registration), builds the unified ErrorCodes const, and re-exports all error primitives. This is the import throw sites use.

Conventions (hard rules)

  • Throw a coded error, not a bare string. throw new Error2(ErrorCodes.X, …). throw new Error('x') only for unreachable guards; BugIndicatingError when the throw site indicates a caller bug (e.g. reading a service before its ready); NotImplementedError('feature') for stubs.
  • Define codes in the owning domain. A domain's codes live in <domain>/errors.ts next to its interfaces, exported as an XxxErrors descriptor — never in _base/errors.
  • One code per failure mode. Codes read domain.reason (e.g. tool.unknown_tool). The set of valid code strings is fixed by the protocol (KimiErrorCode); adding a brand-new code means updating the protocol first. Renaming/removing a code is a major (breaks SDK clients).
  • Import from the facade. Throw sites and cross-domain consumers do import { ErrorCodes, Error2 } from '#/errors'. A domain's own errors.ts references its own descriptor (LoopErrors.codes.X) and imports only from #/_base/errors (never from #/errors, to avoid cycles).
  • Translate foreign errors at the boundary. Provider/HTTP, fs, MCP errors are caught at the domain boundary and re-thrown as the domain's coded error. _base/errors never imports a business domain.
  • Translation is idempotent. A translator (toHostFsError, toStorageIoError, …) returns its input unchanged when it is already the domain's error type, so layered boundaries never double-wrap. The original error always goes to cause.
  • details is structured and JSON-serializable; message is a short human sentence. Paths, errnos, syscalls, scope/key, line numbers go into details; the message must stay readable without them.
  • Cancellation passes through untranslated. A translation boundary that can see a cancellation-class error (UserCancellationError from _base/utils/abort) rethrows it as-is. fs/process translation never encounters cancellation, so those translators do not check for it — apply the rule only at boundaries that actually can.
  • Classify wrapped foreign errors via unwrapErrorCause. Predicates that branch on raw shapes (errno, provider status) test unwrapErrorCause(error), since boundary-translated errors carry the raw error as cause.
  • Branch on code, never instanceof, across the wire. Class identity does not survive serialization. In-process, instanceof Error2 / isCodedError are fine.

Adding a domain error (recipe)

In <domain>/errors.ts:

import { registerErrorDomain, type ErrorDomain } from '#/_base/errors';

export const ToolErrors = {
  codes: {
    UNKNOWN_TOOL: 'tool.unknown_tool',
    EXECUTION_FAILED: 'tool.execution_failed',
  },
  retryable: ['tool.execution_failed'],
  info: {
    'tool.unknown_tool': {
      title: 'Unknown tool',
      retryable: false,
      public: true,
      action: 'Check the tool name passed by the model.',
    },
  },
} as const satisfies ErrorDomain;

registerErrorDomain(ToolErrors);

Then wire it into the facade in src/errors.ts: import ToolErrors, add ...ToolErrors.codes to the ErrorCodes spread, and re-export it. The satisfies ErrorDomain guarantees every code value is a protocol-known ErrorCode, and registerErrorDomain makes its metadata available to serialization.

Domain tiers in practice

The os / persistence / wire domains show the standard shapes:

  • os.fs (HostFsError, os/interface/hostFsErrors.ts) — every IHostFileSystem backend translates raw errnos at its boundary via the pure toHostFsError(err, { path, op }): ENOENT→os.fs.not_found, EISDIR→os.fs.is_directory, ENOTDIR→os.fs.not_directory, EEXIST→os.fs.already_exists, EACCES/EPERM→os.fs.permission_denied, ENOTEMPTY→os.fs.not_empty, everything else os.fs.unknown. details carries { path, op, errno?, syscall? }. Documented boolean semantics (e.g. createExclusive returning false on EEXIST) stay booleans, not errors.
  • os.process (HostProcessError, os/interface/hostProcess.ts)os.process.spawn_failed (details { command, args?, cwd?, errno? }) and os.process.kill_failed; both carry the raw error as cause. Kill keeps its deliberate tolerances: ESRCH is a silent no-op, EPERM degrades to child.kill().
  • storage (StorageError, persistence/interface/storage.ts)storage.not_found / decode_failed / corrupted / io_failed / locked. ENOENT keeps its established absence semantics (read → undefined, list → []) and is not an error; other I/O failures become storage.io_failed (retryable). Codec parse failures become storage.decode_failed with { scope, key, format }; append-log corruption is AppendLogCorruptedError (storage.corrupted). storage.locked is reserved for a store exclusively held by another process — consumers (e.g. FileSessionIndex) catch it explicitly and fall back to their non-read-model path with a one-time warning; there is no silent no-op degradation. (The minidb query-store backend is a multi-process ClusterDb and no longer throws it: peers share the store, and per-shard lock contention surfaces as a transient LockError instead.)
  • wire (WireError, wire/errors.ts)DuplicateOpError (wire.duplicate_op, a build-time bug), CycleError (wire.cycle, details carry the drain depth and a capped op-type sample), and wire.unknown_record: replay skips records whose Op type is absent from OP_REGISTRY (compatibility), reports each skip through onUnexpectedError, and returns { unknownRecords } so the caller knows the restore was lossy.

Serialization & boundary translation

  • toErrorPayload(error): any coded error (incl. deserialized shapes) → its code + retryable from errorInfo; anything else → internal.
  • fromErrorPayload(payload): rehydrates an Error2 for in-process instanceof / isCodedError use at the SDK/RPC boundary.
  • isCodedError(error): structural guard (checks code against the registry), so it works for both Error2 instances and plain objects revived from a payload.
  • The registry is populated when the facade is imported (the package index.ts re-exports it); tests that import a single domain get that domain's codes via its self-registration. errorInfo falls back to { title: code, retryable, public: true } for any unregistered code.

References

  • packages/agent-core-v2/src/_base/errors/ — contract, registry, base classes, serialization.
  • packages/agent-core-v2/src/errors.ts — the aggregating facade.
  • packages/protocol/src/events.ts — the canonical KimiErrorCode wire union.