fix(serve): add opt-in prompt-settled close grace for poll-based SSE clients (#10344)

* fix(serve): fix event_stream_resync_required for poll-based SSE clients

Two root causes were identified from production trace data:

**Root cause 1 (72% of incidents): ring_evicted**
Agentic coding sessions generate 25 000+ events per turn, exhausting
the default ring size of 8 000. When the ring fills, the oldest events
are evicted and a reconnecting client receives `ring_evicted`, forcing
a full resync. Raise `DEFAULT_RING_SIZE` from 8 000 to 32 000 to
provide adequate headroom (~1–2 MB RAM per session at typical event
sizes).

**Root cause 2 (13% of incidents): epoch_reset / session_rebuilt**
Poll-based SSE clients (e.g. DataAgent CLI) disconnect for up to 12 s
between polls. When the last client detaches immediately after a prompt
settles, the daemon closes the session through `last_client_detached`.
The next poll reconnects, restores a new session with a fresh EventBus
epoch, and the old `Last-Event-ID` becomes invalid, triggering resync.

Add `sessionPromptSettledCloseGraceMs` (default 0, preserving existing
behavior for all library consumers). When set > 0, a deferred timer
replaces the immediate `last_client_detached` close after a prompt
result. A reconnecting poll client calls `subscribeEvents`, which clears
the timer and keeps the session alive. Web clients with persistent SSE
connections are completely unaffected — their `clientIds.size` stays > 0
so `entryIsAutoCloseCandidate` never fires.

`qwen serve` exposes `--session-prompt-settled-close-grace-ms` to let
operators tune the window. The DataAgent CLI sets 60 000 ms (60 s) to
comfortably cover its 12 s poll gap with margin.

Fixes: ring_evicted and epoch_reset resync incidents for CLI polling
clients. No behavior change for default-config or web shell deployments.

* fix(serve): address review criticals — timer overwrite and grace-hold consumed on first reconnect

R1-1: schedulePromptSettledClose now clears any pending timer before arming a
new one, so two prompt settles inside one grace window no longer leak the first
timer and close the session at the first settle's deadline.

R1-3: subscribeEvents now calls cancelPromptSettledTimer (timer only) instead
of clearPromptSettledClose (timer + stamp), so the promptSettledAt hold in
entryIsAutoCloseCandidate survives the subscribe → drain → detach cycle.
detachClient calls rearmPromptSettledClose after maybeCloseIdleSession to
reschedule the close for the remaining grace window once the last subscriber
drops.

Also fixes two doc mismatches in bridgeOptions.ts: removes the false claim
that qwen serve sets 60_000 (the default is 0, it is opt-in via flag) and
corrects the scope note — the hold gates every automatic close path, not just
prompt_settled.

* fix(serve): narrow PR to prompt-settled grace and revert default ring-size bump

Per review feedback, scope the change to the opt-in prompt-settled close
grace window and its lifecycle edge cases:

- Revert the global DEFAULT_RING_SIZE change from 32,000 back to 8,000;
  keep only the --event-ring-size option for operators who need it.
- Fix deferred-close handling around subscribeEvents, detachClient,
  closeSession, and killSession so the grace window survives reconnect
  churn and refused closes without orphaning timers.
- Add DAEMON-005 regression tests for the above lifecycle edge cases.
- Expose sessionPromptSettledCloseGraceMs in SDK daemon-status types and
  document the new CLI flag.

Refs: #10344

* fix(cli): tolerate yargs line wrap when checking IPv4 help text

The new --session-prompt-settled-close-grace-ms flag widens yargs' option
column, causing the --hostname description to wrap 127.0.0.0/8 across lines.
Strip whitespace before the substring assertion so the test stays robust.
This commit is contained in:
ChiGao authored and GitHub committed 2026-08-31 14:56:05 +00:00
1 parent 3a0c4c6108
commit be585888f8
18 files changed
+827 -245

No files matched your search

+66 -64
View File
@@ -6,51 +6,52 @@ This page collects every setting that affects the `qwen serve` daemon and its ad
## CLI flags (`qwen serve`)
| Flag | Type | Default | Effect |
| --------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--hostname <host>` | string | `127.0.0.1` | Bind address. Loopback values: any `127.0.0.0/8` address, `localhost`, `::1`, `[::1]`; `localhost` is resolved once and pinned, and trusted mode also verifies the actual listener address before startup completes. A non-loopback result requires a bearer token. `host:port` input is rejected with guidance to use `--port`. |
| `--port <n>` | number | `4170` | Listen port; `0` means ephemeral. |
| `--token <s>` | string | env | Bearer token. Overrides `QWEN_SERVER_TOKEN` and is trimmed at boot. It appears in the process command line, so prefer env in deployments. |
| `--open` | boolean | `false` | Open the mounted Web Shell after runtime readiness. A configured token is delivered as a URL fragment. Bare `--open` remains a silent no-op when browser launch is ineligible. |
| `--open-with-auth` | boolean | `false` | Open the Web Shell with bearer authentication on loopback. Requires an enabled Web Shell and built assets. Reuses a selected `--token` / `QWEN_SERVER_TOKEN`, or generates a process-lifetime 256-bit bearer before listen. In a browser-ineligible environment, starts and prints the secret-bearing fragment URL. Not a `ServeOptions` or SDK setting. |
| `--require-auth` | boolean | `false` | Extends bearer auth to loopback and `/health`; boot refuses to start without a token. |
| `--workspace <dir>` | absolute path / repeatable | `process.cwd()` | Startup workspace runtime; repeat to register additional isolated runtimes. The first is primary. Every value must be absolute and a directory; canonicalized at boot. |
| `--memory-project-scope <mode>` | `git-root` / `workspace` | `workspace` | Project-memory partitioning. `workspace` isolates by exact workspace directory; `git-root` is the legacy compatibility scope shared by workspaces at the same Git root. Overrides `QWEN_CODE_MEMORY_PROJECT_SCOPE`. |
| `--max-sessions <n>` | number | `32` | Per-workspace active session cap. `0` / `Infinity` means unlimited; `NaN` / negative values throw. |
| `--max-total-sessions <n>` | number | derived for multiple startup/restored workspaces | Daemon-wide active session cap. When omitted, a finite default is derived once from the per-workspace cap and startup/restored workspace count. `0` / `Infinity` means unlimited. |
| `--max-pending-prompts-per-session <n>` | number | `5` | Accepted but pending/running prompt cap per session. Excess prompt returns 503. `0` / `Infinity` means unlimited; negative or non-integer values throw. |
| `--max-connections <n>` | number | `256` | HTTP listener `server.maxConnections`; `0` / `Infinity` means unlimited. |
| `--enable-session-shell` | boolean | `false` | Enables direct `POST /session/:id/shell` execution. Effective with bearer auth or trusted-loopback authority; every call must carry a session-bound `X-Qwen-Client-Id`. |
| `--event-ring-size <n>` | number | `8000` | Per-session SSE replay ring; soft cap is `1_000_000`. |
| `--compacted-replay-max-bytes <n>` | positive integer | `4194304` | Byte cap for the bounded in-memory replay snapshot returned by `POST /session/:id/load`; hard cap is `268435456`. |
| `--max-journal-events <n>` | positive safe integer | `10000` | Per-session baseline cap on in-flight `liveJournal` replay entries for the unfinished turn. Adaptive growth can raise it (see `--max-journal-bytes`); pinning either journal flag disables growth. |
| `--max-journal-bytes <n>` | positive safe integer | `8388608` (8 MiB) | Per-session baseline byte cap on the in-flight `liveJournal`. When a turn breaches it, adaptive growth raises the session's caps on demand, toward double but limited by the remaining pool headroom and never past a 256 MiB per-session hard cap — within one daemon-wide pool of 5% of the effective `--memory-budget-mb` (capped at `1024` MB; 0 — growth disabled — when the effective budget is below the 1024 MB minimum), shared by every workspace bridge; without headroom the oldest entries are dropped with a `history_truncated` marker. Pinning either journal flag disables growth. |
| `--memory-budget-mb <n>` | integer in `[1024, 1048576]` | 50% of cgroup-constrained or host memory, capped at the flag maximum (1048576 MB) | Total memory budget for the daemon process tree, capped at resolved available memory. Reported under `limits.memory` in daemon status; it does not size any child process — the one consumer today is adaptive live-journal growth (see `--max-journal-bytes`). Boot rejects out-of-range values. |
| `--memory-pressure-mode <mode>` | `off` \| `observe` | `observe` | Whether the daemon derives a memory-pressure level from its own RSS and V8 heap. Both modes report `runtime.memory.pressure`; only `observe` raises `daemon_memory_pressure`. Root process only; no remediation. |
| `--child-heap-mode <mode>` | `off` \| `observe` | `observe` | Whether the daemon models a per-child heap partition of the budget. `observe` reports it and counts spawns past it; nothing is applied. `off` publishes no partition at all — `maxConcurrentChildren` and `perChildCeilingMb` are both `null`. |
| `--http-bridge` | boolean | `true` | Stage 1 bridge mode. `--no-http-bridge` still falls back to http-bridge and prints to stderr. |
| `--mcp-client-budget <n>` | positive integer | unset | Sets `WorkspaceMcpBudget.clientBudget` and forwards it to the ACP child through `childEnvOverrides`. |
| `--mcp-budget-mode <m>` | `off` / `warn` / `enforce` | `warn` when budget is set, otherwise `off` | Sets `WorkspaceMcpBudget.mode`; `enforce` requires `--mcp-client-budget`. |
| `--external-tool-guard-mode <m>` | `off` / `required` | `off` | Enables the managed ACP external pre-execution Guard. `required` fails startup unless its loopback provider completes the v1 handshake. |
| `--external-tool-guard-endpoint <url>` | loopback HTTP(S) origin | unset | Provider origin used only in `required` mode. It must be origin-only and use `127.0.0.1`, `localhost`, or `::1`; paths, credentials, redirects, and proxy routing are rejected. |
| `--external-tool-guard-timeout-ms <n>` | integer `100..30000` | `3000` | Per-handshake and per-prepare deadline. A timeout fails startup during the handshake or fails the invocation closed during a turn. |
| `--allow-origin <pattern>` | repeatable string | unset | Cross-origin allowlist that replaces the default CORS denial. Wildcard and non-loopback HTTP(S) origins require a token; tokenless HTTP(S) origins must be loopback. |
| `--allow-private-auth-base-url` | boolean | `false` | Allows `/workspace/auth/provider` to install localhost / private-network auth provider `baseUrl`; use only in trusted local development. |
| `--web` / `--no-web` | boolean | `true` | Serve the built Web Shell SPA at the daemon root (`GET /`, `/assets/*`, and `/session/:id` document navigations). These entry points are mounted before `bearerAuth`; API calls still follow configured bearer auth or trusted-loopback authority. `--no-web` leaves the daemon API-only. |
| `--prompt-deadline-ms <n>` | positive integer | unset | Server-side prompt wallclock limit in ms. Timeout aborts and returns an error. |
| `--writer-idle-timeout-ms <n>` | positive integer | unset | Per-SSE-connection idle timeout in ms. The daemon closes the SSE connection when no event is sent for this duration. |
| `--channel-idle-timeout-ms <n>` | non-negative integer | `0` | ACP child auto-reap delay after runtime work drains. Plain preheat is preserved for first use. An explicit keepalive can extend the configured delay; the longer remaining delay wins. |
| `--initialize-timeout-ms <n>` | positive integer | `10000` | ACP child startup deadline (channel factory + initialize handshake) and default request timeout (ms). |
| `--session-restore-timeout-ms <n>` | positive integer | `60000` | ACP session load/resume timeout (ms). When this flag is omitted, an explicitly supplied initialize timeout raises the budget but never lowers it below the default. |
| `--permission-response-timeout-ms <n>` | non-negative integer | `0` | Shared wall-clock timeout for ordinary permission and `ask_user_question` responses. `0` or an omitted flag waits indefinitely; a positive value enables the timer. |
| `--session-reap-interval-ms <n>` | non-negative integer | `60000` | Session reaper scan interval; `0` disables it. |
| `--session-idle-timeout-ms <n>` | non-negative integer | `1800000` | Disconnected-session idle reaping time; `0` disables it. |
| `--rate-limit` / `--no-rate-limit` | boolean | env / off | Enables per-tier HTTP rate limiting for prompt, mutation, and read routes. |
| `--rate-limit-prompt <n>` | positive integer | `10` | Prompt request limit per window; requires rate limiting to be enabled. |
| `--rate-limit-mutation <n>` | positive integer | `30` | Mutation request limit per window; requires rate limiting to be enabled. |
| `--rate-limit-read <n>` | positive integer | `120` | Read request limit per window; requires rate limiting to be enabled. |
| `--rate-limit-window-ms <n>` | integer `>= 1000` | `60000` | Rate limit window length; requires rate limiting to be enabled. |
| no flag | - | - | `QWEN_SERVE_NO_MCP_POOL=1` fully disables the pool. |
| Flag | Type | Default | Effect |
| --------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--hostname <host>` | string | `127.0.0.1` | Bind address. Loopback values: any `127.0.0.0/8` address, `localhost`, `::1`, `[::1]`; `localhost` is resolved once and pinned, and trusted mode also verifies the actual listener address before startup completes. A non-loopback result requires a bearer token. `host:port` input is rejected with guidance to use `--port`. |
| `--port <n>` | number | `4170` | Listen port; `0` means ephemeral. |
| `--token <s>` | string | env | Bearer token. Overrides `QWEN_SERVER_TOKEN` and is trimmed at boot. It appears in the process command line, so prefer env in deployments. |
| `--open` | boolean | `false` | Open the mounted Web Shell after runtime readiness. A configured token is delivered as a URL fragment. Bare `--open` remains a silent no-op when browser launch is ineligible. |
| `--open-with-auth` | boolean | `false` | Open the Web Shell with bearer authentication on loopback. Requires an enabled Web Shell and built assets. Reuses a selected `--token` / `QWEN_SERVER_TOKEN`, or generates a process-lifetime 256-bit bearer before listen. In a browser-ineligible environment, starts and prints the secret-bearing fragment URL. Not a `ServeOptions` or SDK setting. |
| `--require-auth` | boolean | `false` | Extends bearer auth to loopback and `/health`; boot refuses to start without a token. |
| `--workspace <dir>` | absolute path / repeatable | `process.cwd()` | Startup workspace runtime; repeat to register additional isolated runtimes. The first is primary. Every value must be absolute and a directory; canonicalized at boot. |
| `--memory-project-scope <mode>` | `git-root` / `workspace` | `workspace` | Project-memory partitioning. `workspace` isolates by exact workspace directory; `git-root` is the legacy compatibility scope shared by workspaces at the same Git root. Overrides `QWEN_CODE_MEMORY_PROJECT_SCOPE`. |
| `--max-sessions <n>` | number | `32` | Per-workspace active session cap. `0` / `Infinity` means unlimited; `NaN` / negative values throw. |
| `--max-total-sessions <n>` | number | derived for multiple startup/restored workspaces | Daemon-wide active session cap. When omitted, a finite default is derived once from the per-workspace cap and startup/restored workspace count. `0` / `Infinity` means unlimited. |
| `--max-pending-prompts-per-session <n>` | number | `5` | Accepted but pending/running prompt cap per session. Excess prompt returns 503. `0` / `Infinity` means unlimited; negative or non-integer values throw. |
| `--max-connections <n>` | number | `256` | HTTP listener `server.maxConnections`; `0` / `Infinity` means unlimited. |
| `--enable-session-shell` | boolean | `false` | Enables direct `POST /session/:id/shell` execution. Effective with bearer auth or trusted-loopback authority; every call must carry a session-bound `X-Qwen-Client-Id`. |
| `--event-ring-size <n>` | number | `8000` | Per-session SSE replay ring; soft cap is `1_000_000`. |
| `--session-prompt-settled-close-grace-ms <n>` | non-negative integer | `0` | Grace period after a prompt settles before an otherwise-idle session may be auto-closed (ms). Poll-based SSE clients use this window to reconnect without triggering a session rebuild. `0` = immediate close. |
| `--compacted-replay-max-bytes <n>` | positive integer | `4194304` | Byte cap for the bounded in-memory replay snapshot returned by `POST /session/:id/load`; hard cap is `268435456`. |
| `--max-journal-events <n>` | positive safe integer | `10000` | Per-session baseline cap on in-flight `liveJournal` replay entries for the unfinished turn. Adaptive growth can raise it (see `--max-journal-bytes`); pinning either journal flag disables growth. |
| `--max-journal-bytes <n>` | positive safe integer | `8388608` (8 MiB) | Per-session baseline byte cap on the in-flight `liveJournal`. When a turn breaches it, adaptive growth raises the session's caps on demand, toward double but limited by the remaining pool headroom and never past a 256 MiB per-session hard cap — within one daemon-wide pool of 5% of the effective `--memory-budget-mb` (capped at `1024` MB; 0 — growth disabled — when the effective budget is below the 1024 MB minimum), shared by every workspace bridge; without headroom the oldest entries are dropped with a `history_truncated` marker. Pinning either journal flag disables growth. |
| `--memory-budget-mb <n>` | integer in `[1024, 1048576]` | 50% of cgroup-constrained or host memory, capped at the flag maximum (1048576 MB) | Total memory budget for the daemon process tree, capped at resolved available memory. Reported under `limits.memory` in daemon status; it does not size any child process — the one consumer today is adaptive live-journal growth (see `--max-journal-bytes`). Boot rejects out-of-range values. |
| `--memory-pressure-mode <mode>` | `off` \| `observe` | `observe` | Whether the daemon derives a memory-pressure level from its own RSS and V8 heap. Both modes report `runtime.memory.pressure`; only `observe` raises `daemon_memory_pressure`. Root process only; no remediation. |
| `--child-heap-mode <mode>` | `off` \| `observe` | `observe` | Whether the daemon models a per-child heap partition of the budget. `observe` reports it and counts spawns past it; nothing is applied. `off` publishes no partition at all — `maxConcurrentChildren` and `perChildCeilingMb` are both `null`. |
| `--http-bridge` | boolean | `true` | Stage 1 bridge mode. `--no-http-bridge` still falls back to http-bridge and prints to stderr. |
| `--mcp-client-budget <n>` | positive integer | unset | Sets `WorkspaceMcpBudget.clientBudget` and forwards it to the ACP child through `childEnvOverrides`. |
| `--mcp-budget-mode <m>` | `off` / `warn` / `enforce` | `warn` when budget is set, otherwise `off` | Sets `WorkspaceMcpBudget.mode`; `enforce` requires `--mcp-client-budget`. |
| `--external-tool-guard-mode <m>` | `off` / `required` | `off` | Enables the managed ACP external pre-execution Guard. `required` fails startup unless its loopback provider completes the v1 handshake. |
| `--external-tool-guard-endpoint <url>` | loopback HTTP(S) origin | unset | Provider origin used only in `required` mode. It must be origin-only and use `127.0.0.1`, `localhost`, or `::1`; paths, credentials, redirects, and proxy routing are rejected. |
| `--external-tool-guard-timeout-ms <n>` | integer `100..30000` | `3000` | Per-handshake and per-prepare deadline. A timeout fails startup during the handshake or fails the invocation closed during a turn. |
| `--allow-origin <pattern>` | repeatable string | unset | Cross-origin allowlist that replaces the default CORS denial. Wildcard and non-loopback HTTP(S) origins require a token; tokenless HTTP(S) origins must be loopback. |
| `--allow-private-auth-base-url` | boolean | `false` | Allows `/workspace/auth/provider` to install localhost / private-network auth provider `baseUrl`; use only in trusted local development. |
| `--web` / `--no-web` | boolean | `true` | Serve the built Web Shell SPA at the daemon root (`GET /`, `/assets/*`, and `/session/:id` document navigations). These entry points are mounted before `bearerAuth`; API calls still follow configured bearer auth or trusted-loopback authority. `--no-web` leaves the daemon API-only. |
| `--prompt-deadline-ms <n>` | positive integer | unset | Server-side prompt wallclock limit in ms. Timeout aborts and returns an error. |
| `--writer-idle-timeout-ms <n>` | positive integer | unset | Per-SSE-connection idle timeout in ms. The daemon closes the SSE connection when no event is sent for this duration. |
| `--channel-idle-timeout-ms <n>` | non-negative integer | `0` | ACP child auto-reap delay after runtime work drains. Plain preheat is preserved for first use. An explicit keepalive can extend the configured delay; the longer remaining delay wins. |
| `--initialize-timeout-ms <n>` | positive integer | `10000` | ACP child startup deadline (channel factory + initialize handshake) and default request timeout (ms). |
| `--session-restore-timeout-ms <n>` | positive integer | `60000` | ACP session load/resume timeout (ms). When this flag is omitted, an explicitly supplied initialize timeout raises the budget but never lowers it below the default. |
| `--permission-response-timeout-ms <n>` | non-negative integer | `0` | Shared wall-clock timeout for ordinary permission and `ask_user_question` responses. `0` or an omitted flag waits indefinitely; a positive value enables the timer. |
| `--session-reap-interval-ms <n>` | non-negative integer | `60000` | Session reaper scan interval; `0` disables it. |
| `--session-idle-timeout-ms <n>` | non-negative integer | `1800000` | Disconnected-session idle reaping time; `0` disables it. |
| `--rate-limit` / `--no-rate-limit` | boolean | env / off | Enables per-tier HTTP rate limiting for prompt, mutation, and read routes. |
| `--rate-limit-prompt <n>` | positive integer | `10` | Prompt request limit per window; requires rate limiting to be enabled. |
| `--rate-limit-mutation <n>` | positive integer | `30` | Mutation request limit per window; requires rate limiting to be enabled. |
| `--rate-limit-read <n>` | positive integer | `120` | Read request limit per window; requires rate limiting to be enabled. |
| `--rate-limit-window-ms <n>` | integer `>= 1000` | `60000` | Rate limit window length; requires rate limiting to be enabled. |
| no flag | - | - | `QWEN_SERVE_NO_MCP_POOL=1` fully disables the pool. |
## Environment variables
@@ -115,25 +116,26 @@ The daemon constructs each workspace runtime from that workspace's merged settin
`packages/cli/src/serve/types.ts` defines the typed options passed through the public serve APIs. It mirrors the CLI flags above and adds:
| Field | Effect |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `eventRingSize` | Overrides the default per-session ring size. |
| `memoryProjectScope` | `runQwenServe` only; precedence is option, launch env, then `workspace`. Direct `createServeApp` callers use `deps.daemonEnv`. |
| `maxPendingPromptsPerSession` | Pending prompt cap per session; `0` / `Infinity` means unlimited. |
| `mcpPoolActive` | Programmatic switch, defaulting from `QWEN_SERVE_NO_MCP_POOL`. |
| `externalToolGuard` | Optional `{mode:'required', endpoint, token, timeoutMs?}`. Omission is fully off; required mode performs the provider handshake before listening. |
| `allowOrigins` | Cross-origin allowlist (`string[]`), corresponding to `--allow-origin`. |
| `allowPrivateAuthBaseUrl` | Allows private / localhost auth provider `baseUrl` installation. |
| `serveWebShell` | Serve the built Web Shell SPA at the daemon root (default `true`); `false` (the CLI's `--no-web`) leaves the daemon API-only. No effect when the build omits the shell assets. |
| `enableSessionShell` | Enables session shell execution; bearer auth or trusted-loopback authority and a session-bound client id are still required. |
| `promptDeadlineMs` | Prompt wallclock limit. |
| `writerIdleTimeoutMs` | SSE writer idle timeout. |
| `channelIdleTimeoutMs` | ACP child auto-reap delay after runtime work drains. Plain preheat is preserved for first use; active keepalive windows may extend the delay. |
| `initializeTimeoutMs` | ACP child request timeout, including the initialize handshake. |
| `sessionRestoreTimeoutMs` | ACP session load/resume timeout. Precedence: explicit restore value; otherwise an explicit initialize value raises the 60000 default but never lowers it; otherwise 60000. |
| `sessionReapIntervalMs` | Session reaper scan interval. |
| `sessionIdleTimeoutMs` | Disconnected-session idle reaping time. |
| `rateLimit*` | Per-tier HTTP rate limit switch, thresholds, and window. |
| Field | Effect |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `eventRingSize` | Overrides the default per-session ring size. |
| `sessionPromptSettledCloseGraceMs` | Grace period after a prompt settles before an otherwise-idle session may be auto-closed. `0` = immediate close. |
| `memoryProjectScope` | `runQwenServe` only; precedence is option, launch env, then `workspace`. Direct `createServeApp` callers use `deps.daemonEnv`. |
| `maxPendingPromptsPerSession` | Pending prompt cap per session; `0` / `Infinity` means unlimited. |
| `mcpPoolActive` | Programmatic switch, defaulting from `QWEN_SERVE_NO_MCP_POOL`. |
| `externalToolGuard` | Optional `{mode:'required', endpoint, token, timeoutMs?}`. Omission is fully off; required mode performs the provider handshake before listening. |
| `allowOrigins` | Cross-origin allowlist (`string[]`), corresponding to `--allow-origin`. |
| `allowPrivateAuthBaseUrl` | Allows private / localhost auth provider `baseUrl` installation. |
| `serveWebShell` | Serve the built Web Shell SPA at the daemon root (default `true`); `false` (the CLI's `--no-web`) leaves the daemon API-only. No effect when the build omits the shell assets. |
| `enableSessionShell` | Enables session shell execution; bearer auth or trusted-loopback authority and a session-bound client id are still required. |
| `promptDeadlineMs` | Prompt wallclock limit. |
| `writerIdleTimeoutMs` | SSE writer idle timeout. |
| `channelIdleTimeoutMs` | ACP child auto-reap delay after runtime work drains. Plain preheat is preserved for first use; active keepalive windows may extend the delay. |
| `initializeTimeoutMs` | ACP child request timeout, including the initialize handshake. |
| `sessionRestoreTimeoutMs` | ACP session load/resume timeout. Precedence: explicit restore value; otherwise an explicit initialize value raises the 60000 default but never lowers it; otherwise 60000. |
| `sessionReapIntervalMs` | Session reaper scan interval. |
| `sessionIdleTimeoutMs` | Disconnected-session idle reaping time. |
| `rateLimit*` | Per-tier HTTP rate limit switch, thresholds, and window. |
## `BridgeOptions` (programmatic bridge embedding)
+286
View File
@@ -36697,3 +36697,289 @@ describe('createAcpSessionBridge — child-resource refresh', () => {
}
});
});
describe('DAEMON-005: sessionPromptSettledCloseGraceMs — deferred prompt-settled close', () => {
it('keeps session alive during grace window after prompt settles with no subscriber', async () => {
vi.useFakeTimers();
try {
const handle = makeChannel();
const bridge = makeBridge({
channelFactory: async () => handle.channel,
sessionPromptSettledCloseGraceMs: 30_000,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
await bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'hi' }],
});
// Client detaches after receiving the result
await bridge.detachClient(session.sessionId, session.clientId);
expect(bridge.sessionCount).toBe(1);
// Advance to just before the grace window ends — session must survive
await vi.advanceTimersByTimeAsync(29_000);
expect(bridge.sessionCount).toBe(1);
// Advance past the grace window — session should now close
await vi.advanceTimersByTimeAsync(2_000);
await vi.waitFor(() => {
expect(bridge.sessionCount).toBe(0);
});
await bridge.shutdown();
} finally {
vi.useRealTimers();
}
});
it('cancels deferred close when a poll-based client re-subscribes within grace window', async () => {
vi.useFakeTimers();
try {
const handle = makeChannel();
const bridge = makeBridge({
channelFactory: async () => handle.channel,
sessionPromptSettledCloseGraceMs: 30_000,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
await bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'hi' }],
});
// Client detaches — starts the grace timer
await bridge.detachClient(session.sessionId, session.clientId);
expect(bridge.sessionCount).toBe(1);
// Advance into the grace window
await vi.advanceTimersByTimeAsync(10_000);
expect(bridge.sessionCount).toBe(1);
// Poll client reconnects via subscribeEvents — must cancel the timer
const abort = new AbortController();
bridge.subscribeEvents(session.sessionId, { signal: abort.signal });
// Advance well past the original deadline — session must still be alive
await vi.advanceTimersByTimeAsync(60_000);
expect(bridge.sessionCount).toBe(1);
abort.abort();
await bridge.shutdown();
} finally {
vi.useRealTimers();
}
});
it('closes session immediately after prompt settles when grace is 0 (default)', async () => {
vi.useFakeTimers();
try {
const handle = makeChannel();
const bridge = makeBridge({
channelFactory: async () => handle.channel,
// default: 0 — original behavior preserved
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
await bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'hi' }],
});
// Detach the only client
await bridge.detachClient(session.sessionId, session.clientId);
// With grace=0 the session closes synchronously on detach (no timer)
await vi.waitFor(() => {
expect(bridge.sessionCount).toBe(0);
});
await bridge.shutdown();
} finally {
vi.useRealTimers();
}
});
it('re-arms remaining grace after subscribe → Abort churn', async () => {
vi.useFakeTimers();
try {
const handle = makeChannel();
const bridge = makeBridge({
channelFactory: async () => handle.channel,
sessionPromptSettledCloseGraceMs: 30_000,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
await bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'hi' }],
});
// Detach starts the grace timer.
await bridge.detachClient(session.sessionId, session.clientId);
await vi.advanceTimersByTimeAsync(10_000);
expect(bridge.sessionCount).toBe(1);
// Poll client reconnects and then aborts mid-window. Aborting the
// subscription alone does not detach the bridge client; the HTTP layer
// does that via detachClient after the pump settles.
const abort = new AbortController();
bridge.subscribeEvents(session.sessionId, { signal: abort.signal });
await vi.advanceTimersByTimeAsync(5_000);
abort.abort();
await bridge.detachClient(session.sessionId, session.clientId);
// Session must close at the original deadline (10s + 5s elapsed = 15s,
// remaining 15s), not at a fresh 30s after the abort.
await vi.advanceTimersByTimeAsync(14_000);
expect(bridge.sessionCount).toBe(1);
await vi.advanceTimersByTimeAsync(2_000);
await vi.waitFor(() => {
expect(bridge.sessionCount).toBe(0);
});
await bridge.shutdown();
} finally {
vi.useRealTimers();
}
});
it('preserves deferred close when subscribeEvents throws', async () => {
vi.useFakeTimers();
try {
const handle = makeChannel();
const bridge = makeBridge({
channelFactory: async () => handle.channel,
sessionPromptSettledCloseGraceMs: 30_000,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
await bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'hi' }],
});
// Detach starts the grace timer.
await bridge.detachClient(session.sessionId, session.clientId);
await vi.advanceTimersByTimeAsync(10_000);
expect(bridge.sessionCount).toBe(1);
// Force the EventBus subscribe to fail. The pending close must survive.
vi.spyOn(EventBus.prototype, 'subscribe').mockImplementationOnce(() => {
throw new Error('subscribe boom');
});
expect(() => bridge.subscribeEvents(session.sessionId, {})).toThrow(
'subscribe boom',
);
// Session still closes at the original deadline.
await vi.advanceTimersByTimeAsync(19_000);
expect(bridge.sessionCount).toBe(1);
await vi.advanceTimersByTimeAsync(2_000);
await vi.waitFor(() => {
expect(bridge.sessionCount).toBe(0);
});
await bridge.shutdown();
} finally {
vi.useRealTimers();
}
});
it('does not orphan the timer across two prompt settles inside one grace window', async () => {
vi.useFakeTimers();
try {
const handle = makeChannel();
const bridge = makeBridge({
channelFactory: async () => handle.channel,
sessionPromptSettledCloseGraceMs: 30_000,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
// First prompt settles with no subscriber.
await bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'first' }],
});
await bridge.detachClient(session.sessionId, session.clientId);
await vi.advanceTimersByTimeAsync(10_000);
expect(bridge.sessionCount).toBe(1);
// Reconnect, send a second prompt, and detach again. The second
// schedulePromptSettledClose must clear the first timer instead of
// leaking it; if it leaked, the first timer would close the session
// at 30s even though the second prompt reset the clock.
const abort1 = new AbortController();
bridge.subscribeEvents(session.sessionId, { signal: abort1.signal });
await bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'second' }],
});
abort1.abort();
await bridge.detachClient(session.sessionId, session.clientId);
// Advance to 35s absolute: well past the first timer's 30s deadline,
// but only 25s after the second prompt settled.
await vi.advanceTimersByTimeAsync(25_000);
expect(bridge.sessionCount).toBe(1);
// Advance 6s more to pass the second deadline.
await vi.advanceTimersByTimeAsync(6_000);
await vi.waitFor(() => {
expect(bridge.sessionCount).toBe(0);
});
await bridge.shutdown();
} finally {
vi.useRealTimers();
}
});
it('preserves the grace hold when killSession is refused by the agent', async () => {
vi.useFakeTimers();
try {
const handle = makeChannel({
extMethodImpl: (method) => {
if (method === 'qwen/control/session/close') {
throw RequestError.methodNotFound(method);
}
return {};
},
});
const bridge = makeBridge({
channelFactory: async () => handle.channel,
sessionPromptSettledCloseGraceMs: 30_000,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
await bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'hi' }],
});
// Detach starts the grace timer.
await bridge.detachClient(session.sessionId, session.clientId);
await vi.advanceTimersByTimeAsync(10_000);
expect(bridge.sessionCount).toBe(1);
// Kill is refused. The grace hold must survive so a reconnecting client
// can still cancel the deferred close inside the original window.
await expect(bridge.killSession(session.sessionId)).resolves.toBe(false);
expect(bridge.sessionCount).toBe(1);
// Reconnect within the original grace window — the session must stay
// open well past the original deadline because the reconnect cancelled
// the deferred close.
const abort = new AbortController();
bridge.subscribeEvents(session.sessionId, { signal: abort.signal });
await vi.advanceTimersByTimeAsync(60_000);
expect(bridge.sessionCount).toBe(1);
abort.abort();
await bridge.shutdown();
} finally {
vi.useRealTimers();
}
});
});
+167 -1
View File
@@ -1346,6 +1346,19 @@ interface SessionEntry {
* Undefined until the first running turn settles in this bridge.
*/
lastTurnEndedAtMs?: number;
/**
* DAEMON-005: timestamp (Date.now()) when the last prompt settled with no
* active subscriber attached, or `null` when no deferred close is pending.
* Used by `entryIsAutoCloseCandidate` to hold the session open during the
* `sessionPromptSettledCloseGraceMs` window so poll-based clients can
* reconnect without triggering a session-rebuild epoch_reset resync.
*/
promptSettledAt: number | null;
/** Timer handle for the deferred `maybeCloseIdleSession` scheduled by
* `schedulePromptSettledClose`. `undefined` when grace is 0 or no close
* is pending. Cancelled by `clearPromptSettledClose` when a subscriber
* reconnects or the session is explicitly closed / killed. */
promptSettledCloseTimer: ReturnType<typeof setTimeout> | undefined;
}
function isServeDebugLoggingEnabled(): boolean {
@@ -2615,6 +2628,10 @@ const DEFAULT_PERMISSION_TIMEOUT_MS = 0;
const DEFAULT_MAX_PENDING_PER_SESSION = 64;
const DEFAULT_SESSION_REAP_INTERVAL_MS = 60_000;
const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 30 * 60_000;
/** Default grace period for the prompt-settled close deferral. 0 = disabled
* (original behavior). Poll-based CLI callers opt in via BridgeOptions or the
* `qwen serve --session-prompt-settled-close-grace-ms` flag. */
const DEFAULT_SESSION_PROMPT_SETTLED_CLOSE_GRACE_MS = 0;
export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
let liveScreenContextCaptureHandler:
@@ -2951,6 +2968,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
opts.sessionIdleTimeoutMs,
DEFAULT_SESSION_IDLE_TIMEOUT_MS,
);
const sessionPromptSettledCloseGraceMs = resolvePositiveFiniteMs(
opts.sessionPromptSettledCloseGraceMs,
DEFAULT_SESSION_PROMPT_SETTLED_CLOSE_GRACE_MS,
);
let sessionReaper: ReturnType<typeof setInterval> | undefined;
// Tracks the most recent "activity" event for idle-detection by
@@ -3086,6 +3107,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
) {
return false;
}
// DAEMON-005: hold the session open during the prompt-settled grace
// window so a poll-based client can reconnect without forcing a
// session-rebuild epoch_reset resync.
if (
entry.promptSettledAt !== null &&
sessionPromptSettledCloseGraceMs > 0 &&
Date.now() - entry.promptSettledAt < sessionPromptSettledCloseGraceMs
) {
return false;
}
return !childReportsHeldWork(entry);
}
@@ -3101,6 +3132,93 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return entry.closing || entry.activeWorkCloseInFlight;
}
/**
* DAEMON-005: schedule (or immediately fire) the prompt-settled auto-close.
*
* Called from `result.finally` when a prompt settles with no subscriber.
* When `sessionPromptSettledCloseGraceMs > 0`, stamps `promptSettledAt` and
* schedules a deferred `maybeCloseIdleSession` so a reconnecting poll-based
* client has time to cancel the timer via `subscribeEvents` →
* `clearPromptSettledClose`. When grace is 0, fires immediately (original
* behavior).
*/
function schedulePromptSettledClose(entry: SessionEntry): void {
if (entry.promptSettledCloseTimer !== undefined) {
clearTimeout(entry.promptSettledCloseTimer);
entry.promptSettledCloseTimer = undefined;
}
entry.promptSettledAt = Date.now();
if (sessionPromptSettledCloseGraceMs <= 0) {
entry.promptSettledAt = null;
void maybeCloseIdleSession(entry, 'prompt_settled');
return;
}
entry.promptSettledCloseTimer = setTimeout(() => {
entry.promptSettledCloseTimer = undefined;
entry.promptSettledAt = null;
void maybeCloseIdleSession(entry, 'prompt_settled');
}, sessionPromptSettledCloseGraceMs);
// Node timer must not prevent process exit.
entry.promptSettledCloseTimer.unref?.();
}
/**
* DAEMON-005: cancel any pending prompt-settled deferred close and clear the
* stamp. Called when the session is explicitly closed or killed so the stale
* timer never fires.
*/
function clearPromptSettledClose(entry: SessionEntry): void {
if (entry.promptSettledCloseTimer !== undefined) {
clearTimeout(entry.promptSettledCloseTimer);
entry.promptSettledCloseTimer = undefined;
}
entry.promptSettledAt = null;
}
/**
* DAEMON-005: cancel the pending timer but keep `promptSettledAt` set.
*
* Called from `subscribeEvents` when a poll-based client reconnects. Stopping
* the timer prevents a double-fire when the client is actively draining, but
* leaving the stamp in place means `entryIsAutoCloseCandidate` continues to
* hold the session open through the subscriber's churn (subscribe → drain →
* detach). `rearmPromptSettledClose` reschedules the close for the remaining
* grace time once the last subscriber detaches.
*/
function cancelPromptSettledTimer(entry: SessionEntry): void {
if (entry.promptSettledCloseTimer !== undefined) {
clearTimeout(entry.promptSettledCloseTimer);
entry.promptSettledCloseTimer = undefined;
}
}
/**
* DAEMON-005: re-arm the grace timer after a subscriber detaches, using the
* time remaining from the original `promptSettledAt` stamp.
*
* Called from `detachClient` when `promptSettledAt` is still set but no timer
* is running — i.e. after a poll-based client subscribed (cancelling the
* timer via `cancelPromptSettledTimer`) and then disconnected. If the full
* grace window has already elapsed the session is closed immediately.
*/
function rearmPromptSettledClose(entry: SessionEntry): void {
if (entry.promptSettledAt === null) return;
if (entry.promptSettledCloseTimer !== undefined) return;
const elapsed = Date.now() - entry.promptSettledAt;
const remaining = sessionPromptSettledCloseGraceMs - elapsed;
if (remaining <= 0) {
entry.promptSettledAt = null;
void maybeCloseIdleSession(entry, 'prompt_settled');
return;
}
entry.promptSettledCloseTimer = setTimeout(() => {
entry.promptSettledCloseTimer = undefined;
entry.promptSettledAt = null;
void maybeCloseIdleSession(entry, 'prompt_settled');
}, remaining);
entry.promptSettledCloseTimer.unref?.();
}
/**
* Single decision point for "this Session is detached and has nothing left
* to do — let it go".
@@ -6724,6 +6842,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
childHoldsAt: null,
activeWorkCloseInFlight: false,
retryAllowed: false,
promptSettledAt: null,
promptSettledCloseTimer: undefined,
};
if (isReservedStandaloneSessionSourceType(options.sourceType)) {
entry.prepareArtifactWorkspace = () =>
@@ -8536,6 +8656,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
originatorClientId = resolveTrustedClientId(entry, context.clientId);
}
entry.closing = true;
// DAEMON-005: remember the deferred-close stamp before clearing it. If the
// child refuses the close, the session is still alive and may need the
// grace window again once the prompt settles and no subscriber remains.
const deferredCloseStamp = entry.promptSettledAt;
clearPromptSettledClose(entry);
const reason = closeOpts?.reason ?? 'client_close';
writeStderrLine(
`qwen serve: closing session ${JSON.stringify(sessionId)}` +
@@ -8594,6 +8719,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// cleanup removes every bridge entry it owned.
if (isDefinitiveAcpRequestError(error)) {
entry.closing = false;
// DAEMON-005: the child refused the close and the session remains live.
// Restore the prompt-settled grace stamp so the grace window continues
// to hold the session open and a reconnecting poll-based client can
// still cancel the deferred close. Do not re-arm the timer here: the
// deferred-close path would just call closeSessionImpl again and be
// refused again. The idle reaper closes the session once the grace
// window expires.
if (deferredCloseStamp !== null) {
entry.promptSettledAt = deferredCloseStamp;
}
} else if (ci) {
await killChannelWithLog(
ci,
@@ -8874,6 +9009,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
: null,
channelIdleTimeoutMs: configuredChannelIdleTimeoutMs(),
sessionIdleTimeoutMs,
sessionPromptSettledCloseGraceMs,
},
sessionCount: byId.size,
pendingPermissionCount: permissionMediator.pendingCount,
@@ -10223,7 +10359,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// exact entry is still registered — after killSession's eager
// delete the same persisted id can be re-registered as a NEW
// entry by `session/load`, which a late settle must not close.
void maybeCloseIdleSession(entry, 'prompt_settled');
// schedulePromptSettledClose defers the close by
// sessionPromptSettledCloseGraceMs (default 0 = immediate) so
// poll-based clients can reconnect without a session rebuild.
schedulePromptSettledClose(entry);
})
.catch(() => {});
return result;
@@ -10330,6 +10469,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
const raw = entry.events.subscribe(subOpts);
// DAEMON-005: a reconnecting poll-based client stops the deferred timer
// but keeps the grace-hold stamp alive so the session survives the
// subscribe → drain → detach cycle. `detachClient` re-arms the timer
// for the remaining window once the last subscriber drops. Only cancel
// after subscribe succeeds so a failed subscribe preserves the pending
// close.
cancelPromptSettledTimer(entry);
if (!subOpts?.snapshot) return raw;
// A5: wrap the iterator to inject a synthetic `session_snapshot`
@@ -13777,6 +13923,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return true;
}
entry.closing = true;
// DAEMON-005: remember the deferred-close stamp before clearing it. If the
// child refuses the kill, the session is still alive and may need the
// grace window again once the prompt settles and no subscriber remains.
const deferredCloseStamp = entry.promptSettledAt;
clearPromptSettledClose(entry);
const ci = channelInfoForEntry(entry);
if (!ci) {
writeStderrLine(
@@ -13803,6 +13954,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// deferred tombstone completes it on the next settle event.
if (isDefinitiveAcpRequestError(error)) {
entry.closing = false;
// DAEMON-005: the child refused the kill and the session remains live.
// Restore the prompt-settled grace stamp so the grace window continues
// to hold the session open and a reconnecting poll-based client can
// still cancel the deferred close. Do not re-arm the timer here: the
// deferred-close path would just call killSession again and be
// refused again. The idle reaper closes the session once the grace
// window expires.
if (deferredCloseStamp !== null) {
entry.promptSettledAt = deferredCloseStamp;
}
return false;
}
if (ci) {
@@ -13930,6 +14091,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// path settles last. The JSONL transcript on disk survives either way,
// so session/load can restore it later.
await maybeCloseIdleSession(entry, 'last_client_detached');
// DAEMON-005: if a poll-based client subscribed and then dropped (timer
// was cancelled via cancelPromptSettledTimer but stamp is still set),
// re-arm the grace timer for the remaining window so the session closes
// on schedule rather than waiting for the idle reaper.
rearmPromptSettledClose(entry);
},
killAllSync() {
+29
View File
@@ -584,6 +584,35 @@ export interface BridgeOptions {
* Default: 1_800_000 (30 minutes). `0` or `Infinity` disables.
*/
sessionIdleTimeoutMs?: number;
/**
* Grace period after a prompt settles before an otherwise-idle session
* may be auto-closed, in milliseconds.
*
* Poll-based SSE clients (e.g. the DataAgent CLI, which reconnects every
* ~12 s) are disconnected between polls. When a prompt settles while no
* subscriber is attached, the immediate `prompt_settled` auto-close fires
* before the client can reconnect — destroying the session and forcing a
* resume that creates a new EventBus epoch. The client then reconnects
* with its old `Last-Event-ID`, detects the epoch mismatch, and emits a
* `state_resync_required` error (`reason=epoch_reset`).
*
* Setting this to a value greater than the client's maximum poll interval
* (e.g. `60_000` for a 12 s poll cycle) defers the close until the
* reconnecting subscriber can cancel the timer via `subscribeEvents`.
*
* `0` (the default) preserves the original behavior: close fires
* immediately when the session is idle at prompt-settle time. Library
* consumers embedding the bridge directly are unaffected unless they opt
* in by setting this value. For `qwen serve`, pass
* `--session-prompt-settled-close-grace-ms 60000` (or similar) to enable
* protection for poll-based clients.
*
* The grace hold lives in `entryIsAutoCloseCandidate`, which gates every
* automatic close path — including `last_client_detached` and
* `idle_timeout` — so during the window none of those triggers close the
* session either. Only explicit close, kill, and shutdown bypass the hold.
*/
sessionPromptSettledCloseGraceMs?: number;
/**
* Reverse tool channel (issue #5626, Phase 2). Looks up the
* `sendSdkMcpMessage`-shaped sender for a client-hosted MCP server by its
+1
View File
@@ -1179,6 +1179,7 @@ export interface BridgeDaemonStatusLimits {
} | null;
channelIdleTimeoutMs: number;
sessionIdleTimeoutMs: number;
sessionPromptSettledCloseGraceMs: number;
}
export interface BridgeDaemonSessionDiagnostic {
+7 -7
View File
@@ -845,9 +845,9 @@ describe('EventBus', () => {
abort.abort();
});
it('default ring size is 8000 (#3803 §02 target)', async () => {
it('default ring size is 8000', async () => {
const bus = new EventBus();
for (let i = 1; i <= 8001; i++) bus.publish({ type: 'foo', data: i });
for (let i = 1; i <= 8_001; i++) bus.publish({ type: 'foo', data: i });
// After publishing 8001 frames into the default ring, the replay
// backlog should hold the most recent 8000 (oldest dropped).
// A `lastEventId: 0` resume with a queue cap larger than the ring
@@ -858,23 +858,23 @@ describe('EventBus', () => {
// crosses the eviction-detection threshold (earliest > last + 1),
// so an extra synthetic `state_resync_required` frame is emitted
// FIRST. The filter below restricts to live ids, which excludes
// the synthetic (no id), so the original "8000 live frames"
// the synthetic (no id), so the original "N live frames"
// invariant is preserved.
const abort = new AbortController();
const iter = bus.subscribe({
lastEventId: 0,
maxQueued: 9000,
maxQueued: 9_000,
signal: abort.signal,
});
// Collect 8001 frames now: 1 synthetic resync + 8000 live.
const events = await collect(iter, 8001);
const events = await collect(iter, 8_001);
abort.abort();
const liveIds = events
.filter((e) => e.id !== undefined)
.map((e) => e.id as number);
expect(liveIds).toHaveLength(8000);
expect(liveIds).toHaveLength(8_000);
expect(liveIds[0]).toBe(2);
expect(liveIds[liveIds.length - 1]).toBe(8001);
expect(liveIds[liveIds.length - 1]).toBe(8_001);
// The synthetic resync frame is the first one.
expect(events[0]?.type).toBe('state_resync_required');
});
+4 -6
View File
@@ -193,13 +193,11 @@ export const DEFAULT_REPLAY_BUDGET_BYTES = 4 * DEFAULT_MAX_QUEUED_BYTES;
* can emit hundreds of frames (test plan reports 13 for a short
* turn, real workloads can be 10× that or more once tool-call /
* thought streams pile up). 1000 was the original default and could
* be exhausted by a moderate turn before the client reconnected;
* 8000 matches the target set for chatty Stage 1
* sessions, with ~30–60× headroom over a typical-but-busy turn at
* the cost of a few hundred KB of RAM per session. Operators can
* override per-daemon via `qwen serve --event-ring-size <n>`.
* be exhausted by a moderate turn before the client reconnected.
* 8000 is the current daemon design target; operators with very
* long agentic turns can raise it via `qwen serve --event-ring-size <n>`.
*/
export const DEFAULT_RING_SIZE = 8000;
export const DEFAULT_RING_SIZE = 8_000;
/**
* Fraction of the frame and byte caps at which a `slow_client_warning`
* synthetic frame is force-pushed to the at-risk subscriber. The warning
+171 -159
View File
@@ -55,7 +55,11 @@ function buildParser(): Argv {
describe('serve command args', () => {
it('documents the complete IPv4 loopback range', async () => {
expect(await buildParser().getHelp()).toContain('127.0.0.0/8');
// Remove whitespace before checking so a long option name forcing yargs
// to wrap the description does not split the CIDR literal across lines.
expect((await buildParser().getHelp()).replace(/\s/g, '')).toContain(
'127.0.0.0/8',
);
});
it('defaults authenticated open to disabled', () => {
@@ -1042,176 +1046,184 @@ describe('serve startup import boundary', () => {
const startupMs = ecs ? 60_000 : 30_000;
const testMs = ecs ? 70_000 : 40_000;
it('reaches listening through the dev entrypoint without loading interactive Ink internals first', async () => {
const workspace = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-import-boundary-')),
);
const qwenHome = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-import-boundary-home-')),
);
const root = path.resolve(process.cwd(), '../..');
const childEnv: NodeJS.ProcessEnv = {
...process.env,
QWEN_CODE_NO_RELAUNCH: '1',
QWEN_CODE_SUPPRESS_YOLO_WARNING: '1',
QWEN_HOME: qwenHome,
QWEN_RUNTIME_DIR: workspace,
QWEN_SERVE_RATE_LIMIT: '0',
};
delete childEnv['VITEST_WORKER_ID'];
const child = spawn(
process.execPath,
[
path.join(root, 'scripts/dev.js'),
'serve',
'--port',
'0',
'--hostname',
'127.0.0.1',
'--workspace',
workspace,
'--no-web',
'--no-open',
'--rate-limit-prompt',
'0',
'--rate-limit-window-ms',
'1',
],
{
cwd: root,
detached: process.platform !== 'win32',
env: childEnv,
stdio: ['ignore', 'pipe', 'pipe'],
},
);
let stdout = '';
let stderr = '';
let childExited = false;
const exited = new Promise<void>((resolve) => {
child.once('exit', () => {
childExited = true;
resolve();
});
});
const waitForExit = (ms: number) =>
Promise.race([
exited,
new Promise<'timeout'>((resolve) => setTimeout(resolve, ms, 'timeout')),
]);
const cleanup = async () => {
if (child.pid === undefined) return;
const childPid = child.pid;
const signalProcessTree = (signal: NodeJS.Signals) => {
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(childPid), '/T', '/F']);
return;
}
process.kill(-childPid, signal);
it(
'reaches listening through the dev entrypoint without loading interactive Ink internals first',
async () => {
const workspace = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-import-boundary-')),
);
const qwenHome = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-import-boundary-home-')),
);
const root = path.resolve(process.cwd(), '../..');
const childEnv: NodeJS.ProcessEnv = {
...process.env,
QWEN_CODE_NO_RELAUNCH: '1',
QWEN_CODE_SUPPRESS_YOLO_WARNING: '1',
QWEN_HOME: qwenHome,
QWEN_RUNTIME_DIR: workspace,
QWEN_SERVE_RATE_LIMIT: '0',
};
try {
signalProcessTree('SIGTERM');
} catch {
// Process may have already exited.
}
if (!childExited) {
await waitForExit(2_000);
}
if (process.platform !== 'win32') {
delete childEnv['VITEST_WORKER_ID'];
const child = spawn(
process.execPath,
[
path.join(root, 'scripts/dev.js'),
'serve',
'--port',
'0',
'--hostname',
'127.0.0.1',
'--workspace',
workspace,
'--no-web',
'--no-open',
'--rate-limit-prompt',
'0',
'--rate-limit-window-ms',
'1',
],
{
cwd: root,
detached: process.platform !== 'win32',
env: childEnv,
stdio: ['ignore', 'pipe', 'pipe'],
},
);
let stdout = '';
let stderr = '';
let childExited = false;
const exited = new Promise<void>((resolve) => {
child.once('exit', () => {
childExited = true;
resolve();
});
});
const waitForExit = (ms: number) =>
Promise.race([
exited,
new Promise<'timeout'>((resolve) =>
setTimeout(resolve, ms, 'timeout'),
),
]);
const cleanup = async () => {
if (child.pid === undefined) return;
const childPid = child.pid;
const signalProcessTree = (signal: NodeJS.Signals) => {
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(childPid), '/T', '/F']);
return;
}
process.kill(-childPid, signal);
};
try {
signalProcessTree('SIGKILL');
signalProcessTree('SIGTERM');
} catch {
// Process may have already exited.
}
if (!childExited) {
await waitForExit(2_000);
}
}
};
const removeTempDir = async (dir: string) => {
for (let attempt = 0; attempt < 5; attempt++) {
try {
fs.rmSync(dir, { recursive: true, force: true });
return;
} catch (err) {
if (attempt === 4) throw err;
if (process.platform !== 'win32') {
try {
signalProcessTree('SIGKILL');
} catch {
// Process may have already exited.
}
if (!childExited) {
await waitForExit(2_000);
}
}
};
const removeTempDir = async (dir: string) => {
for (let attempt = 0; attempt < 5; attempt++) {
try {
fs.rmSync(dir, { recursive: true, force: true });
return;
} catch (err) {
if (attempt === 4) throw err;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
};
const processGroupHasMembers = (pgid: number): boolean => {
if (process.platform === 'win32') return false;
const result = spawnSync('ps', ['-o', 'pid=', '-g', String(pgid)], {
encoding: 'utf8',
});
if (result.status !== 0) return false;
return result.stdout
.split(/\s+/)
.some((pid) => pid.length > 0 && Number(pid) > 0);
};
const waitForProcessGroupExit = async (pgid: number) => {
if (process.platform === 'win32') return;
for (let attempt = 0; attempt < 20; attempt++) {
if (!processGroupHasMembers(pgid)) return;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
};
const processGroupHasMembers = (pgid: number): boolean => {
if (process.platform === 'win32') return false;
const result = spawnSync('ps', ['-o', 'pid=', '-g', String(pgid)], {
encoding: 'utf8',
});
if (result.status !== 0) return false;
return result.stdout
.split(/\s+/)
.some((pid) => pid.length > 0 && Number(pid) > 0);
};
const waitForProcessGroupExit = async (pgid: number) => {
if (process.platform === 'win32') return;
for (let attempt = 0; attempt < 20; attempt++) {
if (!processGroupHasMembers(pgid)) return;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`serve process group ${pgid} did not exit`);
};
throw new Error(`serve process group ${pgid} did not exit`);
};
try {
const reachedListening = await new Promise<boolean>((resolve, reject) => {
const timeout = setTimeout(() => {
void cleanup();
reject(
new Error(
`serve did not reach listening\nstdout:\n${stdout}\nstderr:\n${stderr}`,
),
);
}, startupMs);
try {
const reachedListening = await new Promise<boolean>(
(resolve, reject) => {
const timeout = setTimeout(() => {
void cleanup();
reject(
new Error(
`serve did not reach listening\nstdout:\n${stdout}\nstderr:\n${stderr}`,
),
);
}, startupMs);
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
if (stdout.includes('qwen serve listening on')) {
clearTimeout(timeout);
void cleanup();
resolve(true);
}
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
if (
stderr.includes('ERR_PACKAGE_PATH_NOT_EXPORTED') ||
stderr.includes('ink/dom') ||
stderr.includes('ink/components/CursorContext')
) {
clearTimeout(timeout);
void cleanup();
reject(new Error(stderr));
}
});
child.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
child.on('exit', (code, signal) => {
if (stdout.includes('qwen serve listening on')) return;
clearTimeout(timeout);
reject(
new Error(
`serve exited before listening: code=${code} signal=${signal}\nstdout:\n${stdout}\nstderr:\n${stderr}`,
),
);
});
});
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
if (stdout.includes('qwen serve listening on')) {
clearTimeout(timeout);
void cleanup();
resolve(true);
}
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
if (
stderr.includes('ERR_PACKAGE_PATH_NOT_EXPORTED') ||
stderr.includes('ink/dom') ||
stderr.includes('ink/components/CursorContext')
) {
clearTimeout(timeout);
void cleanup();
reject(new Error(stderr));
}
});
child.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
child.on('exit', (code, signal) => {
if (stdout.includes('qwen serve listening on')) return;
clearTimeout(timeout);
reject(
new Error(
`serve exited before listening: code=${code} signal=${signal}\nstdout:\n${stdout}\nstderr:\n${stderr}`,
),
);
});
},
);
expect(reachedListening).toBe(true);
} finally {
await cleanup();
if (child.pid !== undefined) {
await waitForProcessGroupExit(child.pid);
expect(reachedListening).toBe(true);
} finally {
await cleanup();
if (child.pid !== undefined) {
await waitForProcessGroupExit(child.pid);
}
await removeTempDir(workspace);
await removeTempDir(qwenHome);
}
await removeTempDir(workspace);
await removeTempDir(qwenHome);
}
}, testMs);
},
testMs,
);
});
+15
View File
@@ -233,6 +233,7 @@ interface ServeArgs {
'session-restore-timeout-ms'?: number;
'session-reap-interval-ms'?: number;
'session-idle-timeout-ms'?: number;
'session-prompt-settled-close-grace-ms'?: number;
'permission-response-timeout-ms'?: number;
'external-tool-guard-mode': 'off' | 'required';
'external-tool-guard-endpoint'?: string;
@@ -608,6 +609,14 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
'Idle timeout before a disconnected session is reaped (ms). ' +
'0 = disabled. Default: 1800000 (30 min).',
})
.option('session-prompt-settled-close-grace-ms', {
type: 'number',
description:
'Grace period after a prompt settles before an otherwise-idle ' +
'session may be auto-closed (ms). Poll-based SSE clients use this ' +
'window to reconnect without triggering a session rebuild. ' +
'0 = disabled (immediate close). Default: 0.',
})
.option('permission-response-timeout-ms', {
type: 'number',
description:
@@ -908,6 +917,12 @@ export const serveCommand: CommandModule<unknown, ServeArgs> = {
...(argv['session-idle-timeout-ms'] !== undefined
? { sessionIdleTimeoutMs: argv['session-idle-timeout-ms'] }
: {}),
...(argv['session-prompt-settled-close-grace-ms'] !== undefined
? {
sessionPromptSettledCloseGraceMs:
argv['session-prompt-settled-close-grace-ms'],
}
: {}),
...(argv['permission-response-timeout-ms'] !== undefined
? {
permissionResponseTimeoutMs:
+2 -1
View File
@@ -34,13 +34,14 @@ const BASE_BRIDGE_SNAPSHOT: BridgeDaemonStatusSnapshot = {
limits: {
maxSessions: 20,
maxPendingPromptsPerSession: 5,
eventRingSize: 8000,
eventRingSize: 8_000,
compactedReplayMaxBytes: 4 * 1024 * 1024,
maxJournalEvents: 10_000,
maxJournalBytes: 8 * 1024 * 1024,
journalGrowth: null,
channelIdleTimeoutMs: 0,
sessionIdleTimeoutMs: 1_800_000,
sessionPromptSettledCloseGraceMs: 0,
},
sessionCount: 0,
pendingPermissionCount: 0,
+3
View File
@@ -195,6 +195,7 @@ interface DaemonStatusLimits {
writerIdleTimeoutMs: number | null;
channelIdleTimeoutMs: number;
sessionIdleTimeoutMs: number;
sessionPromptSettledCloseGraceMs: number;
acpConnectionCap: number | null;
acpPreAttachMaxFramesPerStream: number | null;
acpPreAttachMaxFramesPerConnection: number | null;
@@ -988,6 +989,8 @@ export async function buildDaemonStatusResponse(
writerIdleTimeoutMs: positiveFiniteOrNull(input.opts.writerIdleTimeoutMs),
channelIdleTimeoutMs: bridgeSnapshot.limits.channelIdleTimeoutMs,
sessionIdleTimeoutMs: bridgeSnapshot.limits.sessionIdleTimeoutMs,
sessionPromptSettledCloseGraceMs:
bridgeSnapshot.limits.sessionPromptSettledCloseGraceMs,
acpConnectionCap: acpSnapshot?.connectionCap ?? null,
acpPreAttachMaxFramesPerStream:
acpSnapshot !== undefined ? ACP_PRE_ATTACH_MAX_FRAMES_PER_STREAM : null,
+4
View File
@@ -710,6 +710,10 @@ describe('serve fast path argument parsing', () => {
['session-restore-timeout-ms', ['--session-restore-timeout-ms', '60000']],
['session-reap-interval-ms', ['--session-reap-interval-ms', '1000']],
['session-idle-timeout-ms', ['--session-idle-timeout-ms', '1000']],
[
'session-prompt-settled-close-grace-ms',
['--session-prompt-settled-close-grace-ms', '60000'],
],
[
'permission-response-timeout-ms',
['--permission-response-timeout-ms', '1000'],
+1
View File
@@ -57,6 +57,7 @@ const NUMBER_OPTIONS = new Map<
['sessionRestoreTimeoutMs', 'session-restore-timeout-ms'],
['sessionReapIntervalMs', 'session-reap-interval-ms'],
['sessionIdleTimeoutMs', 'session-idle-timeout-ms'],
['sessionPromptSettledCloseGraceMs', 'session-prompt-settled-close-grace-ms'],
['permissionResponseTimeoutMs', 'permission-response-timeout-ms'],
['rateLimitPrompt', 'rate-limit-prompt'],
['rateLimitMutation', 'rate-limit-mutation'],
@@ -544,13 +544,14 @@ function makeBridge(
limits: {
maxSessions: 20,
maxPendingPromptsPerSession: 5,
eventRingSize: 8000,
eventRingSize: 8_000,
compactedReplayMaxBytes: 4 * 1024 * 1024,
maxJournalEvents: 10_000,
maxJournalBytes: 8 * 1024 * 1024,
journalGrowth: null,
channelIdleTimeoutMs: 0,
sessionIdleTimeoutMs: 1_800_000,
sessionPromptSettledCloseGraceMs: 0,
},
sessionCount: live.size,
pendingPermissionCount: 0,
+16 -3
View File
@@ -118,13 +118,14 @@ const BASE_BRIDGE_SNAPSHOT: BridgeDaemonStatusSnapshot = {
limits: {
maxSessions: 20,
maxPendingPromptsPerSession: 5,
eventRingSize: 8000,
eventRingSize: 8_000,
compactedReplayMaxBytes: 4 * 1024 * 1024,
maxJournalEvents: 10_000,
maxJournalBytes: 8 * 1024 * 1024,
journalGrowth: null,
channelIdleTimeoutMs: 0,
sessionIdleTimeoutMs: 1_800_000,
sessionPromptSettledCloseGraceMs: 0,
},
sessionCount: 0,
pendingPermissionCount: 0,
@@ -4843,6 +4844,7 @@ describe('runQwenServe telemetry validation', () => {
compactedReplayMaxBytes: 1024,
channelIdleTimeoutMs: 60_000,
sessionRestoreTimeoutMs: 90_000,
sessionPromptSettledCloseGraceMs: 5_000,
serveWebShell: false,
},
{
@@ -4867,6 +4869,7 @@ describe('runQwenServe telemetry validation', () => {
compactedReplayMaxBytes: 1024,
eventRingSize: 1234,
sessionRestoreTimeoutMs: 90_000,
sessionPromptSettledCloseGraceMs: 5_000,
permissionPolicy: 'local-only',
onChannelDelivery: expect.any(Function),
});
@@ -4875,6 +4878,7 @@ describe('runQwenServe telemetry validation', () => {
compactedReplayMaxBytes: 1024,
eventRingSize: 1234,
sessionRestoreTimeoutMs: 90_000,
sessionPromptSettledCloseGraceMs: 5_000,
permissionPolicy: 'local-only',
onChannelDelivery: expect.any(Function),
});
@@ -6741,7 +6745,10 @@ describe('runQwenServe session reaper timeout validation', () => {
}
async function runWithReaperOption(
optionName: 'sessionReapIntervalMs' | 'sessionIdleTimeoutMs',
optionName:
| 'sessionReapIntervalMs'
| 'sessionIdleTimeoutMs'
| 'sessionPromptSettledCloseGraceMs',
value: number,
) {
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'qws-rt-')));
@@ -6776,6 +6783,10 @@ describe('runQwenServe session reaper timeout validation', () => {
['sessionIdleTimeoutMs', 1.5],
['sessionIdleTimeoutMs', Number.NaN],
['sessionIdleTimeoutMs', Number.POSITIVE_INFINITY],
['sessionPromptSettledCloseGraceMs', -1],
['sessionPromptSettledCloseGraceMs', 1.5],
['sessionPromptSettledCloseGraceMs', Number.NaN],
['sessionPromptSettledCloseGraceMs', Number.POSITIVE_INFINITY],
] as const)('rejects invalid %s=%s', async (optionName, value) => {
await expect(runWithReaperOption(optionName, value)).rejects.toThrow(
optionName,
@@ -6785,6 +6796,7 @@ describe('runQwenServe session reaper timeout validation', () => {
it.each([
['sessionReapIntervalMs', 0],
['sessionIdleTimeoutMs', 0],
['sessionPromptSettledCloseGraceMs', 0],
] as const)(
'keeps %s=0 as the disabled sentinel',
async (optionName, value) => {
@@ -11021,7 +11033,7 @@ describe('runQwenServe runtime startup failures', () => {
maxSessions: 1,
maxPendingPromptsPerSession: 5,
listenerMaxConnections: 256,
eventRingSize: 8000,
eventRingSize: 8_000,
compactedReplayMaxBytes: 4 * 1024 * 1024,
maxJournalEvents: 10_000,
maxJournalBytes: 8 * 1024 * 1024,
@@ -11029,6 +11041,7 @@ describe('runQwenServe runtime startup failures', () => {
writerIdleTimeoutMs: null,
channelIdleTimeoutMs: 0,
sessionIdleTimeoutMs: 1_800_000,
sessionPromptSettledCloseGraceMs: 0,
acpConnectionCap: null,
memory: expect.objectContaining({ enforced: false }),
},
+40 -3
View File
@@ -40,7 +40,10 @@ import {
normalizeMaxJournalEvents,
type JournalGrowthSessionLimit,
} from '@qwen-code/acp-bridge/replayWindowLimits';
import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus';
import {
DEFAULT_RING_SIZE,
type BridgeEvent,
} from '@qwen-code/acp-bridge/eventBus';
import { resolveSessionRestoreTimeoutMs } from '@qwen-code/acp-bridge/sessionRestoreTimeout';
import type { NdJsonMessageObservation } from '@qwen-code/acp-bridge/ndJsonStream';
import { getDeviceFlowRegistry } from './auth/device-flow.js';
@@ -418,8 +421,8 @@ const RUNTIME_STARTUP_TIMEOUT_ENV = 'QWEN_SERVE_RUNTIME_STARTUP_TIMEOUT_MS';
const MAX_EVENT_RING_SIZE = 1_000_000;
const DEFAULT_MAX_SESSIONS = 32;
const DEFAULT_MAX_PENDING_PROMPTS_PER_SESSION = 5;
const DEFAULT_EVENT_RING_SIZE = 8000;
const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 30 * 60_000;
const DEFAULT_SESSION_PROMPT_SETTLED_CLOSE_GRACE_MS = 0;
const WORKSPACE_SETTING_SCOPE =
'Workspace' as import('../config/settings.js').SettingScope;
@@ -2308,6 +2311,12 @@ function sessionIdleTimeoutMs(value: number | undefined): number {
: DEFAULT_SESSION_IDLE_TIMEOUT_MS;
}
function sessionPromptSettledCloseGraceMs(value: number | undefined): number {
return value !== undefined
? channelIdleTimeoutMs(value)
: DEFAULT_SESSION_PROMPT_SETTLED_CLOSE_GRACE_MS;
}
function currentServeFeaturesForRunQwenServe(
opts: ServeOptions,
sessionShellCommandEnabled: boolean,
@@ -2717,7 +2726,7 @@ function createBootstrapServeApp(input: {
opts.maxPendingPromptsPerSession,
),
listenerMaxConnections: listenerMaxConnections(opts.maxConnections),
eventRingSize: opts.eventRingSize ?? DEFAULT_EVENT_RING_SIZE,
eventRingSize: opts.eventRingSize ?? DEFAULT_RING_SIZE,
compactedReplayMaxBytes:
opts.compactedReplayMaxBytes ?? DEFAULT_COMPACTED_REPLAY_MAX_BYTES,
maxJournalEvents: opts.maxJournalEvents ?? DEFAULT_MAX_JOURNAL_EVENTS,
@@ -2726,6 +2735,9 @@ function createBootstrapServeApp(input: {
writerIdleTimeoutMs: positiveFiniteOrNull(opts.writerIdleTimeoutMs),
channelIdleTimeoutMs: channelIdleTimeoutMs(opts.channelIdleTimeoutMs),
sessionIdleTimeoutMs: sessionIdleTimeoutMs(opts.sessionIdleTimeoutMs),
sessionPromptSettledCloseGraceMs: sessionPromptSettledCloseGraceMs(
opts.sessionPromptSettledCloseGraceMs,
),
acpConnectionCap: null,
acpPreAttachMaxFramesPerStream: null,
acpPreAttachMaxFramesPerConnection: null,
@@ -4065,6 +4077,13 @@ async function runQwenServeImpl(
);
}
}
if (opts.sessionPromptSettledCloseGraceMs !== undefined) {
if (!isNonNegativeIntegerMs(opts.sessionPromptSettledCloseGraceMs)) {
throw new TypeError(
`Invalid sessionPromptSettledCloseGraceMs: ${opts.sessionPromptSettledCloseGraceMs}. Must be a non-negative integer (milliseconds, 0 = disabled).`,
);
}
}
if (opts.initializeTimeoutMs !== undefined) {
if (!isPositiveIntegerMs(opts.initializeTimeoutMs)) {
throw new TypeError(
@@ -5481,6 +5500,12 @@ async function runQwenServeImpl(
...(opts.sessionIdleTimeoutMs !== undefined
? { sessionIdleTimeoutMs: opts.sessionIdleTimeoutMs }
: {}),
...(opts.sessionPromptSettledCloseGraceMs !== undefined
? {
sessionPromptSettledCloseGraceMs:
opts.sessionPromptSettledCloseGraceMs,
}
: {}),
...(opts.permissionResponseTimeoutMs !== undefined
? { permissionResponseTimeoutMs: opts.permissionResponseTimeoutMs }
: {}),
@@ -6042,6 +6067,12 @@ async function runQwenServeImpl(
...(opts.sessionIdleTimeoutMs !== undefined
? { sessionIdleTimeoutMs: opts.sessionIdleTimeoutMs }
: {}),
...(opts.sessionPromptSettledCloseGraceMs !== undefined
? {
sessionPromptSettledCloseGraceMs:
opts.sessionPromptSettledCloseGraceMs,
}
: {}),
...(opts.permissionResponseTimeoutMs !== undefined
? { permissionResponseTimeoutMs: opts.permissionResponseTimeoutMs }
: {}),
@@ -6708,6 +6739,12 @@ async function runQwenServeImpl(
...(opts.sessionIdleTimeoutMs !== undefined
? { sessionIdleTimeoutMs: opts.sessionIdleTimeoutMs }
: {}),
...(opts.sessionPromptSettledCloseGraceMs !== undefined
? {
sessionPromptSettledCloseGraceMs:
opts.sessionPromptSettledCloseGraceMs,
}
: {}),
...(opts.permissionResponseTimeoutMs !== undefined
? { permissionResponseTimeoutMs: opts.permissionResponseTimeoutMs }
: {}),
+7
View File
@@ -327,6 +327,13 @@ export interface ServeOptions {
sessionReapIntervalMs?: number;
/** Session idle timeout in ms. 0 = disabled. Default: 1800000 (30 min). */
sessionIdleTimeoutMs?: number;
/**
* Grace period after a prompt settles before an otherwise-idle session may
* be auto-closed, in ms. 0 = disabled (original behavior). Set to a value
* greater than the client's max SSE poll interval to prevent session rebuilds
* for poll-based clients. Default: 0.
*/
sessionPromptSettledCloseGraceMs?: number;
/**
* ACP child request timeout, including the `initialize` handshake,
* in ms. Must be a positive
@@ -709,6 +709,12 @@ export interface DaemonStatusReport {
writerIdleTimeoutMs: number | null;
channelIdleTimeoutMs: number;
sessionIdleTimeoutMs: number;
/**
* Grace period after a prompt settles before an otherwise-idle session may
* be auto-closed, in ms. `0` means the feature is disabled and the session
* closes immediately. Additive — older daemons omit this field.
*/
sessionPromptSettledCloseGraceMs?: number;
acpConnectionCap: number | null;
acpPreAttachMaxFramesPerStream?: number | null;
acpPreAttachMaxFramesPerConnection?: number | null;