Skip to content

fix(mcp): keep typed dependency failures retryable through chat prevalidation - #426

Open
audichuang wants to merge 1 commit into
LodyAI:mainfrom
audichuang:fix/mcp-dependency-error-classification
Open

fix(mcp): keep typed dependency failures retryable through chat prevalidation#426
audichuang wants to merge 1 commit into
LodyAI:mainfrom
audichuang:fix/mcp-dependency-error-classification

Conversation

@audichuang

@audichuang audichuang commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Related issue

Refs #400

Stated plainly so it is not discovered in review: this PR changes none of the
four outputs #400 reproduces.
Defect 2's example payload comes from
syncMachineFlockDocsForRead (session.ts:979-991), which propagates the plain
Error thrown one frame deeper by syncFlockDocOrThrow
(apps/cli/src/lib/loro/doc.ts:973-976) on the session_create path — untyped, so it still lands in the
COMMAND_REJECTED fallback, and PR #411 is removing that producer at source for
#398. A network outage also cannot produce the condition this PR fixes, because
LocalDaemonAvailabilityError originates from local unix-socket IPC.

What this PR does is apply defect 2's rule — an unavailable dependency must not
be reported as a command refusal — to the one chat prevalidation producer that
already carries typed retryability, bringing the chat catches in line with the
create ones. The Issue stays open.

Problem / pressure

session_create (lody-mcp-server.ts:2726) already rethrows
LocalDaemonAvailabilityError, and session_create_many (:3253) already
returns it per item with the producer's own code and retryability. The two chat
entry points did not, even though chat prevalidation raises it.

validateSessionChatTarget has two typed dependency-failure producers:

  • syncWorkspaceMetaForRead (session.ts:3175) raises WorkspaceSyncUnavailableError
  • ensureTargetMachineOnline (session.ts:3187) routes a target on the machine
    answering the MCP call into ensureLocalRuntimeAvailabledispatchLocalControl
    classifyLocalDaemonIpcError, which returns LocalDaemonAvailabilityError
    with DAEMON_BUSY / DAEMON_NOT_RUNNING / DAEMON_PROTOCOL_ERROR and the
    retryability that goes with each

Only the first was passed through. The second was flattened to
COMMAND_REJECTED (single) or INVALID_ITEM (batch), both retryable: false,
so a caller honouring retryable gives up on a condition that clears on its own.
COMMAND_REJECTED also states the command was evaluated and refused on its
merits, which an unreachable local daemon is not.

Reachable triggers, since the obvious one is not the likely one: the daemon
exiting between the invocation-context RPC and machine/status
(DAEMON_NOT_RUNNING, not retryable); a 5xx, a 408, a 429, or a stalled
machine/status while other RPCs answer (DAEMON_BUSY, retryable); or a
non-retryable protocol answer — any other 4xx, or a 2xx whose body fails
validation (DAEMON_PROTOCOL_ERROR). A daemon
unresponsive for the whole call never reaches this frame — readActiveInvocationContext
converts its IpcTimeoutError into a plain Error one frame earlier and it
surfaces as INTERNAL_ERROR, which is #400's defect 1 and out of scope here.

Summary

  • startSessionChatOperation: rethrow LocalDaemonAvailabilityError alongside
    the existing WorkspaceSyncUnavailableError passthrough. mcpErrorResult
    already maps it via toLodyError, so no mapping change was needed.
  • startSessionChatManyOperation: return the failure on the item via
    batchFailure(error.code, error.message, error.retryable, item.label)
    identical to what session_create_many already does for this type. Daemon
    availability is per target machine; a workspace sync failure is workspace-wide
    and still aborts the batch.
  • Two regression tests injecting that producer's error type at the prevalidation
    boundary of both entry points.

Untyped errors are deliberately left alone — see Deliberately not done.

Before / after

Before After
lody_session_chat when the daemon has exited, stalls machine/status, or answers a protocol error: COMMAND_REJECTED, retryable: false the producer's own code (DAEMON_NOT_RUNNING / DAEMON_BUSY / DAEMON_PROTOCOL_ERROR) with its own retryable
lody_session_chat_many marks the item INVALID_ITEM, retryable: false marks the item with the producer's code and retryable
A caller honouring retryable abandons a transient local-daemon condition It sees the condition is retryable. For single chat nothing was accepted, so a resend is a fresh attempt; for a batch the Operation is accepted carrying the failed item, so recovery follows the batch's own retry semantics
Chat catches disagreed with the create catches on the same error type All four prevalidation catches now apply one rule

Test plan

  • pnpm --filter lody run typecheck — passed.
  • cd apps/cli && pnpm exec vitest run --no-file-parallelism — 250 files, 2495
    tests passed, 1 skipped, 0 failed.
  • cd apps/cli && pnpm exec vitest run src/mcp/lody-mcp-server-chat-sync.test.ts
    — 5 passed.
  • Red without the fix, reproducible on this branch. From the repository root:
    git show origin/main:apps/cli/src/mcp/lody-mcp-server.ts > apps/cli/src/mcp/lody-mcp-server.ts,
    re-run the test file, and both new tests fail reporting COMMAND_REJECTED and
    INVALID_ITEM; git checkout HEAD -- apps/cli/src/mcp/lody-mcp-server.ts
    restores them. Verified.
  • pnpm lint — 0 errors, and the warning count is identical on this branch and on
    its merge base (9822 locally on both). The absolute number differs between a local
    run and CI's clean checkout; the delta is what this claims.
  • pnpm check:public-boundary — passed (4111 files, 22 manifests on this branch;
    main has grown since, so a post-rebase run reports a larger count).
  • pnpm --filter lody run format — no changes outside the two edited files.

Not run: no live reproduction. The producer chain was established by reading
origin/main; the tests inject the error type that chain returns rather than
provoking it end to end.

Context handoff

Instructions for reviewing agents

  • Review focus: the two edited prevalidation catches in lody-mcp-server.ts.
    The new branch is reachable because validateSessionChatTarget calls
    ensureTargetMachineOnline unconditionally (session.ts:3187), unlike the create
    sites which pass skipMachineAvailabilityCheck: true.
  • Decisions to challenge: (1) batch items failing individually rather than
    aborting — copied from session_create_many:3253, but a batch whose items all
    target this machine now yields N identical failures. (2) Leaving untyped errors in
    the COMMAND_REJECTED fallback instead of widening the fix.
  • Plausible failures / evidence gaps: no live reproduction; the frame this fixes
    is narrow, and the more common local-daemon failure surfaces earlier as
    INTERNAL_ERROR through readActiveInvocationContext.

Authoring context

  • User goal / directives: fix a maintainer-filed MCP defect with a narrow,
    reviewable change, and avoid scope that would need a maintainer design decision.
  • Constraints / non-goals: no changes in apps/cli/src/commands/session.ts
    PR fix(cli): allow local session creation on stale Flock sync #411 is rewriting the local-project resolution call sites there for [Bug] Session create hard-fails on a best-effort Flock freshness sync when the network is degraded #398, with a
    different resolution (make the freshness sync best-effort) than reclassifying. No
    new error codes, no shared schema changes, no transport-level classification.
  • Risk-bearing decisions: batch chat items now carry the producer's own code and
    retryability instead of INVALID_ITEM / false. No shape change — the item already
    failed with INVALID_ITEM; only code and retryable differ.
  • Destructive or irreversible behavior: none. No migration and no persisted-shape
    change; failed batch items were already persisted as failures.
  • Deliberately not done or tested: [Bug] MCP error classification: network failures marked non-retryable and unactionable operationId retry advice #400's defects 1, 3 and 4, and the untyped
    producers. Defect 1 needs a decision about where transport failures get classified —
    listWorkspacesForToken, postSessionControl, or the normalizeMcpError funnel —
    and whether a dependency-unavailable code should exist; the same decision governs the
    plain Errors from the local-project Flock sync and from the remote-target branch of
    ensureTargetMachineOnline (session.ts:1867), both of which this PR leaves in the
    fallback. Defects 3 and 4 turn on whether WorkspaceSyncUnavailableError.toLodyError()
    should return this.message rather than the shared constant — today it discards the
    instance message, including the --offline hint built in buildOfflineHint. All of
    these look like maintainer calls rather than something to fold in here.
  • Unknowns / confidence: high on the chat producer chain, which was traced producer
    to consumer and independently re-derived before opening. Lower on how much the fixed
    frame matters in practice, which is why the reachable triggers are spelled out above
    rather than implied.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9d03233b9f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +3455 to +3456
if (error instanceof LocalDaemonAvailabilityError) {
return batchFailure(error.code, error.message, error.retryable, item.label);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep retryable batch failures outside the finished operation

When local prevalidation returns DAEMON_BUSY, this stores the item as terminally failed; finishOperationWhenEveryItemIsTerminal then finishes the Operation, and findMatchingRetry returns that finished snapshot on every retry. This contradicts DAEMON_BUSY_MESSAGE, which instructs the caller to reuse the same operationId, so the prompt can never be retried after the daemon recovers. A retryable dependency failure must remain recoverable under that ID or abort before accepting the batch rather than becoming a terminal item.

Useful? React with 👍 / 👎.

…lidation

session_create already rethrows LocalDaemonAvailabilityError from its
prevalidation catch, and session_create_many already returns it per item with
the producer's own code and retryability. The two chat entry points did not.

Chat prevalidation raises both typed dependency errors, not just the sync one.
validateSessionChatTarget calls syncWorkspaceMetaForRead, which raises
WorkspaceSyncUnavailableError, and then ensureTargetMachineOnline, which for a
target on the machine answering the MCP call routes through
ensureLocalRuntimeAvailable -> dispatchLocalControl -> classifyLocalDaemonIpcError
and raises LocalDaemonAvailabilityError with code DAEMON_BUSY and
retryable: true.

Only the first was passed through. The second was flattened into
COMMAND_REJECTED (single) or INVALID_ITEM (batch), both with retryable: false,
so the retryability the CLI had just computed was destroyed one frame later and
a caller honouring `retryable` gave up on a condition that clears on its own.
COMMAND_REJECTED also asserts the command was evaluated and refused on its
merits, which is not what an unreachable local daemon is.

Batch items carry the failure per item rather than aborting the batch, matching
session_create_many for the same error type: daemon availability is per target
machine, while a workspace sync failure is workspace-wide and still aborts.

Untyped errors are deliberately left alone. Both the local-project Flock sync on
the create path and the remote-target branch of ensureTargetMachineOnline throw
plain Errors, so they still reach the COMMAND_REJECTED fallback; classifying
those is the separate transport-classification question LodyAI#400 raises first.

Refs LodyAI#400

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Model: claude-opus-5[1m]
@audichuang
audichuang force-pushed the fix/mcp-dependency-error-classification branch from 9d03233 to 458525d Compare September 6, 2026 06:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant