Skip to content

fix(auth): keep the OAuth listTools request alive across every phase - #241

Open
umutkeltek wants to merge 2 commits into
openclaw:mainfrom
umutkeltek:fix/daemon-listtools-progress-heartbeat
Open

fix(auth): keep the OAuth listTools request alive across every phase#241
umutkeltek wants to merge 2 commits into
openclaw:mainfrom
umutkeltek:fix/daemon-listtools-progress-heartbeat

Conversation

@umutkeltek

@umutkeltek umutkeltek commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Resubmission of #237 with the correctness gap fixed and a clean history, now updated for the Codex and ClawSweeper review round.

The gap in #237

resolveOperationSocketBudget sized the listTools socket at 2 * timeoutMs + 5s: one phase for OAuth, one for a single tools/list. But McpRuntime.listTools() walks nextCursor and gives every page its own timeoutMs, so an OAuth wait plus two or more slow pages outlives the socket. The client sees a transport timeout, restarts the daemon, resends the listing, and the user gets the second OAuth flow this PR exists to prevent.

Any constant multiple has the same defect — it encodes a phase count the runtime does not promise.

The fix: observe the daemon instead of predicting it

While a request is in flight the daemon host emits newline-delimited progress frames. The client treats each frame as proof of life and restarts its socket deadline. That turns the socket deadline from an operation budget into a liveness budget:

  • a request survives however many sequential phases it needs — OAuth wait plus N pages — with no arithmetic on either side;
  • a daemon that actually goes silent is still torn down and restarted, exactly as before;
  • the operation deadline stays where it belongs: the daemon enforces it and answers operation_timeout, which the client does not retry.

resolveOperationSocketBudget and its 5s grace are gone. The listTools socket uses the caller deadline verbatim. Both peers decode frames incrementally (DaemonFrameDecoder), so the daemon's own status probe and stop handshake stay readable and a response with no trailing newline still parses. The daemon protocol is versioned, so a daemon started before this change is replaced rather than left speaking the old wire format.

Real behavior proof

A local OAuth-protected MCP server (dynamic client registration, PKCE S256, authorization code, bearer-gated /mcp) that paginates tools/list across 4 pages at 4s each. mcporter auth runs the real interactive browser flow — the browser launch is shimmed so approval lands 4s after the URL opens — routed through the real keep-alive daemon (lifecycle.mode = keep-alive, confirmed daemon pid). MCPORTER_OAUTH_TIMEOUT_MS=6000, so every individual phase is comfortably inside its deadline and only the total exceeds a fixed budget.

Before — 462865b, the head that was closed (2 * 6000 + 5000 = 17s socket budget):

[14:45:41.300] mcp -> 401 (no valid bearer token), advertising protected resource metadata
[14:45:45.352] authorize -> issuing code, redirecting to loopback callback
[14:45:45.362] token -> PKCE verified, access token issued
[14:45:45.388] tools/list page 1/4
[14:45:49.394] tools/list page 2/4
[14:45:53.398] tools/list page 3/4
[14:45:57.402] tools/list page 4/4
[14:45:58.729] tools/list page 1/4      <-- socket expired at ~17s; daemon restarted, listing replayed
[14:46:02.732] tools/list page 2/4
[14:46:06.735] tools/list page 3/4
[14:46:10.737] tools/list page 4/4

CLI exit 0, elapsed 34s
tools/list page 1 requests : 2
tools/list requests total  : 8

After — this branch:

[14:44:51.258] mcp -> 401 (no valid bearer token), advertising protected resource metadata
[14:44:55.771] authorize -> issuing code, redirecting to loopback callback
[14:44:55.777] token -> PKCE verified, access token issued
[14:44:55.795] tools/list page 1/4
[14:44:59.800] tools/list page 2/4
[14:45:03.805] tools/list page 3/4
[14:45:07.810] tools/list page 4/4

CLI exit 0, elapsed 21s
tools/list page 1 requests : 1
tools/list requests total  : 4

One authorization, one completed listing, no replay, no daemon restart. The replay in the "before" run begins 17.3s after the listing started, which is exactly the 2 * timeoutMs + 5s budget — the failure mode described in the #237 review, reproduced and then removed.

(The provider is a local fixture rather than a third-party service so the run is inspectable and repeatable; it exercises the real SDK OAuth client path — discovery, registration, PKCE, code exchange, bearer-gated MCP — not a stub.)

Review round: what changed

[P1] Liveness frames could outlive a wedged request. The premise as stated — that listResources/readResource have no operation timeout — does not hold: every SDK request carries DEFAULT_REQUEST_TIMEOUT_MSEC (60s) and every OAuth wait carries DEFAULT_OAUTH_CODE_TIMEOUT_MS (300s). But the underlying worry was real and I found a stronger case than the one reported: McpRuntime.listTools() walks nextCursor with no guard, so a server that repeats a cursor pages forever — and heartbeats would then keep the client waiting forever. Both are now closed:

  • operations with a fixed phase count (at most one connect plus one MCP request) carry an absolute ceiling equal to the sum of the deadlines the daemon already applies to those phases. It is derived, not guessed, and can only fire once every real deadline has been blown; a server that accepts and never answers now yields a non-retryable operation_timeout instead of an indefinite wait.
  • listTools, whose page count is data-dependent, keeps its per-page deadline and gains a repeated-cursor guard in the runtime.

[P2] Short deadlines could expire before the first frame. Fixed, and slightly further than suggested: the first frame is written immediately when the request is dispatched, and the cadence is derived from the caller's own deadline (resolveProgressInterval, sent on the request envelope) rather than fixed at 250ms. An immediate frame alone would still lose a sub-250ms deadline on the second gap.

[P3] Release-owned changelog. Correct — checked against this repo's history: my own merged #234 did not touch CHANGELOG.md; the maintainer added the entry at release with the thanks @… credit line. The entry has been removed from this branch.

Regression coverage

tests/daemon-listtools-progress.test.ts drives the real client transport against the real host framing over a socket:

  • survives an OAuth wait followed by several paginated tools/list pages — an OAuth wait plus three delayed pages, every phase longer than the socket deadline. Asserts the tools resolve, listTools ran exactly once (no replay), one listTools request reached the daemon, and launchDaemonDetached was never called (no restart). Verified it fails without the mechanism: stubbing out the progress emitter makes it die with Daemon did not stop before restart could begin.
  • reaches a deadline shorter than the default progress interval — the P2 case.
  • still trips the socket deadline when the daemon stops sending progress frames — silence is still fatal.
  • walks every cursor page but refuses to page forever on a repeated cursor — the P1 case.

tests/daemon-client-timeout.test.ts pins the budget itself: the listTools socket deadline equals the caller deadline, so a phase-count multiplier cannot come back unnoticed.

Housekeeping

  • Clean commit history, no AI co-author trailers, single author.
  • Rebased onto current main.
  • pnpm check and pnpm test green locally (851 passed, 3 skipped); CI green on Ubuntu, macOS and Windows.

@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: ab62f071c3

ℹ️ 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 thread src/daemon/host.ts Outdated
preParsedRequest
);
socket.write(JSON.stringify(response), () => {
const stopProgress = startProgressFrames(socket, preParsedRequest?.id ?? 'unknown');

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 Limit heartbeat frames to operations with their own deadline

When a keep-alive server accepts resources/list or resources/read but never responds, this unconditional heartbeat continues while the daemon event loop remains healthy, and the client resets its 30-second socket deadline on every frame. Those runtime methods have no operation timeout, so the CLI now waits forever instead of timing out, restarting the daemon, and retrying as it did before this commit. Restrict these heartbeats to the bounded listTools path or retain an absolute recovery deadline for the other methods.

Useful? React with 👍 / 👎.

Comment thread src/daemon/protocol.ts
// deadline, so a request stays alive for as many phases as it needs -- an OAuth
// code wait plus any number of paginated `tools/list` pages -- without the
// client having to predict how many phases there will be.
export const DAEMON_PROGRESS_INTERVAL_MS = 250;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Emit a heartbeat before short idle deadlines

When an API caller supplies ListToolsOptions.timeoutMs <= 250 (also accepted by --oauth-timeout), the client arms that same socket timeout immediately, but the host waits 250 ms before its first progress frame. Because the daemon starts the operation timeout only after receiving and dispatching the request, the socket deadline can fire first, causing invoke() to classify this as a transport failure and restart/replay the request instead of returning the non-retryable operation_timeout. Send an initial frame immediately or coordinate the interval with the requested timeout.

Useful? React with 👍 / 👎.

@umutkeltek
umutkeltek force-pushed the fix/daemon-listtools-progress-heartbeat branch from ab62f07 to 9a296ab Compare July 27, 2026 13:57
@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P1 Urgent regression or broken agent/channel workflow affecting real users now. merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. labels Jul 27, 2026
@clawsweeper

clawsweeper Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed July 27, 2026, 11:21 AM ET / 15:21 UTC.

ClawSweeper review

What this changes

The PR forwards the OAuth timeout through auth and listTools, adds versioned daemon progress frames to preserve long multi-page OAuth discovery requests, and adds timeout, cursor-loop, lifecycle, and protocol-compatibility coverage.

Merge readiness

⚠️ Ready for maintainer review - 3 items remain

Keep this PR open for maintainer merge review. The prior review findings are addressed: OAuth authorization timeouts now reach the listTools request, progress frames refresh the daemon client's idle socket deadline during paginated discovery, and the patch adds bounded-operation and repeated-cursor protections. The branch has credible real-run proof and green cross-platform checks, but its versioned local-daemon protocol and timeout recovery changes warrant deliberate maintainer acceptance before landing.

Priority: P1
Reviewed head: 0e37f7e383af6d742d53c1037012ae79c84f0807
Owner decision: Required. See Decision needed.

Review scores

Measure Result What it means
Overall readiness 🐚 platinum hermit (4/6) Strong live proof and focused regression coverage support a well-scoped fix, with the remaining consideration being deliberate review of the daemon protocol upgrade.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (live_output): The PR body includes an inspectable after-fix live run against a local OAuth-protected, paginated MCP server, showing one authorization and four requests without daemon restart or listing replay.
Patch quality 🐚 platinum hermit (4/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (live_output): The PR body includes an inspectable after-fix live run against a local OAuth-protected, paginated MCP server, showing one authorization and four requests without daemon restart or listing replay.
Evidence reviewed 5 items OAuth timeout reaches discovery: The branch adds timeoutMs to ListToolsOptions, forwards it through the runtime and daemon wrapper, and makes the auth command pass its OAuth timeout into the tool-listing request so the SDK's default request cap no longer wins first.
Idle liveness is distinct from operation deadlines: The daemon client decodes progress frames and resets the socket's idle deadline, while the host maps bounded operation failures to operation_timeout; the wrapper does not restart and replay those operation-timeout failures.
Protocol upgrade is intentionally compatibility-aware: The daemon metadata carries a protocol version and the client treats an older metadata version as stale, replacing a pre-change daemon rather than mixing old and new socket framing.
Findings None None.
Security None None.

How this fits together

mcporter auth discovers a server's tools while OAuth authorization may be in progress. For keep-alive servers, that request travels through a local daemon socket; the daemon runs the MCP operation and returns the completed tool list to the CLI.

flowchart LR
  A[mcporter auth command] --> B[Runtime tool discovery]
  B --> C[Keep-alive daemon client]
  C --> D[Versioned local socket protocol]
  D --> E[Daemon host and OAuth flow]
  E --> F[Paginated MCP tools listing]
  E --> G[Progress frames]
  G --> C
  F --> H[Tool list returned to CLI]
Loading

Decision needed

Question Recommendation
Should this PR's v2 local-daemon protocol upgrade, including replacement of an existing older daemon, be accepted as the compatibility strategy for preventing OAuth discovery replay? Accept the protocol upgrade: Merge the versioned framing and stale-daemon replacement because it preserves an unambiguous client/host contract and the focused tests cover the upgrade path.

Why: The implementation and proof support the fix, but accepting the one-time keep-alive daemon replacement and the new liveness contract is an operational compatibility choice for maintainers.

Before merge

  • Resolve merge risk (P1) - Upgrading replaces any already-running pre-v2 local daemon; that is intentional, but maintainers should confirm that a one-time daemon restart is acceptable for active keep-alive users.
  • Resolve merge risk (P1) - Progress frames now govern client-side recovery from a busy daemon, so the host-side operation ceilings and repeated-cursor guard must remain aligned with future daemon methods.
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Patch surface 17 files affected; 1,004 added, 99 removed The fix changes CLI timeout propagation, runtime behavior, daemon framing, lifecycle reconciliation, and regression coverage rather than a single isolated timeout.
New focused suites 2 test files added The new suites exercise socket liveness and runtime timeout forwarding alongside updates to existing daemon lifecycle tests.

Root-cause cluster

Relationship: canonical
Canonical: #241
Summary: This PR is the active replacement for the closed earlier OAuth timeout PR and carries the added liveness fix required for paginated discovery.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Merge-risk options

Maintainer options:

  1. Accept the versioned daemon replacement (recommended)
    Merge with the intentional stale-daemon restart behavior, relying on the protocol-version and lifecycle coverage to keep old and new framing from mixing.
  2. Pause for an alternate upgrade contract
    Keep the PR open if maintainers need a compatibility bridge that avoids replacing an already-running daemon during upgrade.

Technical review

Best possible solution:

Land the focused timeout and liveness design if maintainers accept the v2 local-daemon upgrade behavior, keeping operation deadlines enforced by the daemon and idle recovery enforced by the client.

Do we have a high-confidence way to reproduce the issue?

Yes, with high confidence from the supplied real local OAuth fixture and the source path: current main's fixed socket budget can expire across an OAuth wait plus multiple tools/list pages, causing daemon restart and replay.

Is this the best way to solve the issue?

Yes. Refreshing an idle socket deadline from daemon progress while retaining daemon-enforced operation timeouts is a narrower solution than guessing a larger phase-count budget; versioning the local protocol avoids mixed framing during upgrade.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 4062298d66ef.

Labels

Label justifications:

  • P1: The patch addresses an OAuth discovery failure that can restart the keep-alive daemon and replay user authorization during normal authentication.
  • merge-risk: 🚨 compatibility: The local daemon protocol changes version and intentionally replaces older running daemons during upgrade.
  • merge-risk: 🚨 availability: Client recovery now depends on daemon progress frames plus host-side operation limits, affecting timeout and restart behavior for keep-alive requests.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR body includes an inspectable after-fix live run against a local OAuth-protected, paginated MCP server, showing one authorization and four requests without daemon restart or listing replay.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body includes an inspectable after-fix live run against a local OAuth-protected, paginated MCP server, showing one authorization and four requests without daemon restart or listing replay.

Evidence

What I checked:

  • OAuth timeout reaches discovery: The branch adds timeoutMs to ListToolsOptions, forwards it through the runtime and daemon wrapper, and makes the auth command pass its OAuth timeout into the tool-listing request so the SDK's default request cap no longer wins first. (src/runtime.ts:61, 3c1026d451e8)
  • Idle liveness is distinct from operation deadlines: The daemon client decodes progress frames and resets the socket's idle deadline, while the host maps bounded operation failures to operation_timeout; the wrapper does not restart and replay those operation-timeout failures. (src/daemon/client.ts:260, 0e37f7e383af)
  • Protocol upgrade is intentionally compatibility-aware: The daemon metadata carries a protocol version and the client treats an older metadata version as stale, replacing a pre-change daemon rather than mixing old and new socket framing. (src/daemon/protocol.ts:1, 0e37f7e383af)
  • Focused regression coverage: The added socket-level regression suite covers multi-phase OAuth discovery without replay, short deadlines, silent-daemon expiry, and repeated cursors; separate tests pin timeout forwarding and stale protocol replacement. (tests/daemon-listtools-progress.test.ts:1, 0e37f7e383af)
  • Real behavior proof and checks: The PR body supplies before/after output from a real local OAuth-protected, four-page MCP server: the old fixed socket budget replays the listing, while this branch performs one authorization and one four-page listing. GitHub checks are green on Ubuntu, macOS, and Windows. (0e37f7e383af)

Likely related people:

  • umutkeltek: Authored the current daemon/OAuth transport changes and previously landed the related standalone-SSE transport fix, indicating sustained work in the runtime transport area. (role: recent adjacent contributor; confidence: medium; commits: 57cf3b19aba6, 3c1026d451e8, 0e37f7e383af; files: src/daemon/client.ts, src/daemon/host.ts, src/runtime.ts)

Rank-up moves

Optional improvements that raise the rating; they are not merge blockers.

  • Confirm the intended stale-daemon replacement behavior and host-side timeout contract before merge.

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (3 earlier review cycles)
  • reviewed 2026-07-27T14:02:32.762Z sha 9a296ab :: needs real behavior proof before merge. :: [P1] Restrict liveness frames to bounded tool-listing requests | [P2] Write the first progress frame before short deadlines expire | [P3] Remove the release-owned changelog entry
  • reviewed 2026-07-27T14:52:13.417Z sha 6d72274 :: needs changes before merge. :: [P2] Keep progress cadence within the caller deadline
  • reviewed 2026-07-27T15:12:18.243Z sha 2d6d820 :: needs maintainer review before merge. :: none

`mcporter auth` runs the interactive OAuth browser flow inside the SDK's
`tools/list` request, but `listTools` never forwarded a per-request timeout. The
SDK's 60s DEFAULT_REQUEST_TIMEOUT_MSEC killed the request long before the 300s
OAuth code wait could finish, so slow providers could never be authorized --
every `mcporter auth` died with `MCP error -32001: Request timed out`.

Mirror the existing `callTool` timeout forwarding:

- add `timeoutMs` to `ListToolsOptions` and pass `{ timeout,
  resetTimeoutOnProgress, maxTotalTimeout }` to `client.listTools`, with
  `raceWithTimeout` as an outer guard;
- have the `auth` path pass `MCPORTER_OAUTH_TIMEOUT_MS` (default 300s) so the
  request lives at least as long as the OAuth code wait;
- carry `timeoutMs` across the daemon protocol so keep-alive servers get the
  same deadline, and report a phase that blows it as `operation_timeout` so the
  keep-alive wrapper stops treating it as a dead server worth restarting;
- version the daemon protocol so a daemon started before this change is
  replaced instead of silently dropping the new field.
@umutkeltek
umutkeltek force-pushed the fix/daemon-listtools-progress-heartbeat branch from 9a296ab to 6d72274 Compare July 27, 2026 14:47
@umutkeltek

Copy link
Copy Markdown
Contributor Author

Thanks — both review passes found real things. All three items are addressed and the PR body now carries an inspectable before/after run. Summary:

P1 — liveness frames outliving a wedged request. The stated premise doesn't hold: listResources/readResource do have an operation timeout, because every SDK request carries DEFAULT_REQUEST_TIMEOUT_MSEC (60s, shared/protocol.js) and every OAuth wait carries DEFAULT_OAUTH_CODE_TIMEOUT_MS (300s). But chasing it turned up a stronger case than the one reported: McpRuntime.listTools() walks nextCursor with no guard, so a server that repeats a cursor pages forever — and heartbeats would then keep a caller waiting forever. Both ends are now closed:

  • fixed-phase operations (at most one connect plus one MCP request) carry an absolute ceiling equal to the sum of the deadlines the daemon already applies to those phases — derived rather than guessed, so it can only fire once every real deadline has been blown. A server that accepts and never answers now returns a non-retryable operation_timeout instead of hanging.
  • listTools — the one operation whose phase count is data-dependent — keeps its per-page deadline and gains a repeated-cursor guard.

P2 — short deadlines expiring before the first frame. Fixed, and a step further than suggested: an immediate first frame alone still loses a sub-250ms deadline on the second gap, so the cadence is now derived from the caller's own deadline (sent on the request envelope) and the first frame is written immediately on dispatch.

P3 — release-owned changelog. Correct, and I checked it against this repo's history rather than taking it on trust: my own merged #234 didn't touch CHANGELOG.md — the maintainer added the entry at release with the thanks @… credit. Entry removed.

Real behavior proof is in the PR body: a local OAuth-protected MCP server (dynamic registration, PKCE S256, bearer-gated /mcp) paginating tools/list over 4 pages, driven by a real mcporter auth browser flow through the real keep-alive daemon. On 462865b the listing is replayed — page 1 requested twice, 8 tools/list requests, 34s — with the replay starting 17.3s in, exactly the 2 * timeoutMs + 5s budget. On this branch: one authorization, 4 requests, 21s, no restart, no replay.

Each item has regression coverage; pnpm check and pnpm test are green locally (851 passed) and CI is green on Ubuntu, macOS and Windows.

@clawsweeper clawsweeper Bot added proof: sufficient Contributor real behavior proof is sufficient. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Jul 27, 2026
@umutkeltek
umutkeltek force-pushed the fix/daemon-listtools-progress-heartbeat branch from 6d72274 to 2d6d820 Compare July 27, 2026 15:08
@umutkeltek

Copy link
Copy Markdown
Contributor Author

Fixed the remaining P2 — good catch, the finding is exact.

resolveProgressInterval() clamped the cadence up to a 25ms floor, which meant that for any caller deadline at or below 25ms the interval overshot the very deadline it was meant to refresh: the immediate frame landed, then the socket expired before the next one, and invoke() classified that as a dead transport and replayed. The floor was the one constant left in a mechanism whose whole point is not to use constants, so it is gone rather than lowered — the cadence is now min(250, max(1, floor(deadline / 3))), strictly inside the caller's deadline for every deadline above 1ms.

I did not reject short values at the input boundary instead: timeoutMs: 1 is accepted today and pinned by an existing test (clamps daemon status preflight timeout for tiny per-call timeouts), so tightening the boundary would be a separate behavior change. A 1ms deadline stays unachievable at any cadence — as it was before progress frames existed.

Regression added below the old floor, asserting the invariant directly rather than racing a timer: resolveProgressInterval(n) < n across 2, 3, 5, 12, 24, 25, 74, 75, 300, 30_000, 300_000, plus the 1ms boundary and the default-cadence fallbacks.

pnpm check and pnpm test green (853 passed, 3 skipped).

@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels Jul 27, 2026
Sizing the daemon socket deadline for a fixed number of phases cannot work.
`McpRuntime.listTools()` issues one `tools/list` request per cursor page and
gives each its own `timeoutMs`, so an OAuth wait followed by two or more slow
pages outlives any constant multiple of the caller deadline. The socket then
expires mid-flight, the client restarts the daemon and resends the listing, and
the user is walked through a second OAuth flow.

Stop predicting the phase count and observe the daemon instead. While a request
is in flight the host emits newline-delimited progress frames, and the client
treats each frame as proof of life and restarts its socket deadline. The deadline
becomes a liveness budget rather than an operation budget: a request survives
however many phases it needs, while a daemon that goes silent is still torn down
and restarted exactly as before.

Liveness alone would be too weak, so every operation stays provably bounded:

- the first frame goes out immediately and the cadence is derived from the
  caller's own deadline, so a deadline shorter than the default interval cannot
  expire before the daemon has proved it is alive;
- operations with a fixed phase count -- at most one connect plus one MCP
  request -- carry an absolute ceiling equal to the sum of the deadlines the
  daemon already applies to those phases, so a server that accepts a request and
  never answers yields a non-retryable `operation_timeout` rather than an
  indefinite wait;
- `listTools` keeps its per-page deadline and gains a repeated-cursor guard, so
  the one operation whose phase count is data-dependent cannot page forever.

Both peers decode frames incrementally, so the daemon's own status probe and stop
handshake stay readable, and a response with no trailing newline still parses.

Regression coverage drives the real client transport and the real host framing
over a socket: an OAuth wait followed by three delayed `tools/list` pages
resolves with one `listTools` request, no replay, and no daemon relaunch. Sibling
cases cover a deadline below the default frame interval, a daemon that goes
silent, and a server that repeats a pagination cursor.
@umutkeltek
umutkeltek force-pushed the fix/daemon-listtools-progress-heartbeat branch from 2d6d820 to 0e37f7e Compare July 27, 2026 15:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. P1 Urgent regression or broken agent/channel workflow affecting real users now. proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant