diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 39772fcd061..706f2fb505b 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -28,6 +28,7 @@ import { AuthControlPlaneRuntimeLive } from "../auth/Layers/AuthControlPlane.ts" import { AuthControlPlane } from "../auth/Services/AuthControlPlane.ts"; import type { AuthControlPlaneShape } from "../auth/Services/AuthControlPlane.ts"; import { ServerConfig, type ServerConfigShape } from "../config.ts"; +import { PROJECT_CLI_LIVE_SERVER_TIMEOUT_MS } from "../timeouts.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts"; @@ -66,7 +67,6 @@ const ProjectCliRuntimeLive = Layer.mergeAll( ), ); -const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); const OrchestrationHttpErrorResponse = Schema.Struct({ error: Schema.String, }); @@ -85,7 +85,7 @@ const withProjectCliSessionToken = ( ); const withProjectCliLiveServerTimeout = (effect: Effect.Effect) => - effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); + effect.pipe(Effect.timeout(Duration.millis(PROJECT_CLI_LIVE_SERVER_TIMEOUT_MS))); const runLiveServerRequest = ( request: HttpClientRequest.HttpClientRequest, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index ce6db0589d9..dc2f0d3df8a 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -105,39 +105,6 @@ export const MODE_CHANGE_METHOD: AbortMethod = "end-force"; */ export const MAINTENANCE_METHOD: AbortMethod = "end-force"; -/** - * ACP_WIRE_STALL_WARN_MS — soft threshold for "CLI seems stuck" - * detection. While a turn is active, the adapter watches incoming - * JSON-RPC frames on the ACP wire (every notification, response, or - * frame from CLI). If no frame arrives for this many milliseconds, - * a WARN log is emitted once per stall. No teardown — just visibility. - */ -export const ACP_WIRE_STALL_WARN_MS = 3_600_000; - -/** - * ACP_WIRE_STALL_KILL_MS — hard threshold. After this much wire - * silence during an active turn the adapter declares the session - * stuck and runs `abortSession(ctx, "end-force")`. The next user - * message starts a fresh CLI child and `session/load`s the prior - * transcript via the persisted resumeCursor. Backstop so a stuck - * agent can never strand the chat indefinitely. - */ -export const ACP_WIRE_STALL_KILL_MS = 7_200_000; - -/** - * POST_ANSWER_RESUME_TIMEOUT_MS — one-shot timeout used by the - * post-answer resume probe (`armPostAnswerResumeProbe` in - * `AcpPendingRequests.ts`). After the adapter resolves a pending - * user-input / approval / plan-approval Deferred, a fiber sleeps this - * long and then checks `ctx.wireActivity.lastIncomingAt`. If CLI has - * not produced a single inbound frame in that window, the session is - * declared wedged and `abortSession(ctx, MAINTENANCE_METHOD)` is - * called. Probe measurement showed the typical resume latency is - * ~4 ms, so 10 s is ~2500× margin — generous but tight enough to - * recover snappily. - */ -export const POST_ANSWER_RESUME_TIMEOUT_MS = 3_600_000; - /** * CONTEXT_WINDOW_TOKENS — total context window size advertised to the * UI for the current CLI binary. Hardcoded rather than extracted from diff --git a/apps/server/src/daemonLauncher.ts b/apps/server/src/daemonLauncher.ts index 241584ec1a7..948144bf5bc 100644 --- a/apps/server/src/daemonLauncher.ts +++ b/apps/server/src/daemonLauncher.ts @@ -15,6 +15,7 @@ import { resolveStartupCli, runStartupChecks } from "./ru-fork/preflight/preflig import { ARROW_DIM, ARROW_OK, ARROW_WARN, paint } from "./ru-fork/local-startup/cliPaint.ts"; import { printDaemonReadyBanner } from "./ru-fork/local-startup/daemonReadyBanner.ts"; import { deriveServerPaths } from "./config.ts"; +import { DAEMON_HEALTH_PROBE_TIMEOUT_MS, DAEMON_SPAWN_TIMEOUT_MS } from "./timeouts.ts"; import { expandHomePath, resolveBaseDir } from "./os-jank.ts"; import { readPersistedServerRuntimeState } from "./serverRuntimeState.ts"; import { Open } from "./open.ts"; @@ -51,12 +52,10 @@ const lineKV = (key: string, value: string): string => ` ${paint.dim(key.padEnd(6))} ${paint.bold(paint.magenta(value))}`; const headline = (arrow: string, text: string): string => ` ${arrow} ${paint.bold(text)}`; -const HEALTH_PROBE_TIMEOUT_MS = 1_000; const SPAWN_HEALTH_POLL_INTERVAL_MS = 200; // `Effect.retry({ times: N })` means 1 initial attempt + N retries. // 75 retries × 200ms = 15s of additional polling on top of the first try. const SPAWN_HEALTH_POLL_RETRIES = 75; -const SPAWN_TIMEOUT_MS = 5_000; const STOP_DRAIN_INTERVAL_MS = 100; const STOP_DRAIN_RETRIES = 50; // ~5s drain budget after POST /shutdown @@ -64,7 +63,7 @@ const probeHealth = (origin: string) => Effect.tryPromise({ try: async () => { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), HEALTH_PROBE_TIMEOUT_MS); + const timer = setTimeout(() => controller.abort(), DAEMON_HEALTH_PROBE_TIMEOUT_MS); try { const response = await fetch(`${origin}/health`, { signal: controller.signal, @@ -195,10 +194,10 @@ const spawnDetachedServer = (input: { }) => spawnDetachedServerCallback(input).pipe( Effect.timeoutOrElse({ - duration: Duration.millis(SPAWN_TIMEOUT_MS), + duration: Duration.millis(DAEMON_SPAWN_TIMEOUT_MS), orElse: () => Effect.fail( - new Error(`spawn did not signal 'spawn' or 'error' within ${SPAWN_TIMEOUT_MS}ms`), + new Error(`spawn did not signal 'spawn' or 'error' within ${DAEMON_SPAWN_TIMEOUT_MS}ms`), ), }), ); @@ -215,7 +214,7 @@ const fetchPairingStartupUrl = (origin: string) => Effect.tryPromise({ try: async () => { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), HEALTH_PROBE_TIMEOUT_MS); + const timer = setTimeout(() => controller.abort(), DAEMON_HEALTH_PROBE_TIMEOUT_MS); try { const response = await fetch(`${origin}/pair/startup`, { method: "POST", @@ -362,7 +361,7 @@ export const runStopCommand = (input: StopCommandInput) => const sent = yield* Effect.tryPromise({ try: async () => { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), HEALTH_PROBE_TIMEOUT_MS); + const timer = setTimeout(() => controller.abort(), DAEMON_HEALTH_PROBE_TIMEOUT_MS); try { const response = await fetch(`${state.value.origin}/shutdown`, { method: "POST", diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 8c4d00101d9..18b50c081ac 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -51,6 +51,7 @@ import type { GitManagerServiceError } from "@t3tools/contracts"; import { GitVcsDriver, type GitStatusDetails } from "../vcs/GitVcsDriver.ts"; import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts"; import type { ChangeRequest } from "@t3tools/contracts"; +import { GIT_COMMIT_TIMEOUT_MS } from "../timeouts.ts"; export interface GitActionProgressReporter { readonly publish: (event: GitActionProgressEvent) => Effect.Effect; @@ -90,7 +91,6 @@ export class GitManager extends Context.Service()( "t3/git/GitManager", ) {} -const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; @@ -1224,7 +1224,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { } : null; const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, { - timeoutMs: COMMIT_TIMEOUT_MS, + timeoutMs: GIT_COMMIT_TIMEOUT_MS, ...(commitProgress ? { progress: commitProgress } : {}), }); if (currentHookName !== null) { diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index de547cf57cd..575ace2dd6e 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -14,6 +14,8 @@ import { } from "./stream/collectUint8StreamText.ts"; // ru-fork: shared spawn policy (shell:false default for exe + bash-routing). import { resolveSpawn } from "./ru-fork/spawn/policy.ts"; +import { haltOnExit } from "./ru-fork/spawn/haltOnExit.ts"; +import { PROCESS_RUNNER_DEFAULT_TIMEOUT_MS } from "./timeouts.ts"; export interface ProcessRunInput { readonly command: string; @@ -95,7 +97,6 @@ export class ProcessRunner extends Context.Service( effect: Effect.Effect, input: ProcessRunInput, ): Effect.Effect> { - const timeout = Duration.fromInputUnsafe(input.timeout ?? DEFAULT_TIMEOUT); + const timeout = Duration.fromInputUnsafe(input.timeout ?? PROCESS_RUNNER_DEFAULT_TIMEOUT_MS); const timeoutBehavior = input.timeoutBehavior ?? "error"; return effect.pipe( @@ -289,7 +290,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( args: input.args, cwd: input.cwd, streamName: "stdout", - stream: child.stdout, + stream: child.stdout.pipe(haltOnExit(child.exitCode)), maxOutputBytes, outputMode, truncatedMarker, @@ -299,7 +300,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( args: input.args, cwd: input.cwd, streamName: "stderr", - stream: child.stderr, + stream: child.stderr.pipe(haltOnExit(child.exitCode)), maxOutputBytes, outputMode, truncatedMarker, diff --git a/apps/server/src/provider/Layers/CliAdapter.ts b/apps/server/src/provider/Layers/CliAdapter.ts index 2329f829ae1..0052408da78 100644 --- a/apps/server/src/provider/Layers/CliAdapter.ts +++ b/apps/server/src/provider/Layers/CliAdapter.ts @@ -47,16 +47,18 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { - ACP_WIRE_STALL_KILL_MS, - ACP_WIRE_STALL_WARN_MS, CONTEXT_WINDOW_TOKENS, MAINTENANCE_METHOD, MODE_CHANGE_METHOD, - POST_ANSWER_RESUME_TIMEOUT_MS, ServerConfig, SLASH_COMMAND_NOTIFICATION_METHODS, STOP_BUTTON_METHOD, } from "../../config.ts"; +import { + ACP_WIRE_STALL_KILL_MS, + ACP_WIRE_STALL_WARN_MS, + POST_ANSWER_RESUME_TIMEOUT_MS, +} from "../../timeouts.ts"; import { buildPostAnswerResumeProbe, settleAndDelete, diff --git a/apps/server/src/provider/Layers/CliProvider.ts b/apps/server/src/provider/Layers/CliProvider.ts index cca32f59a39..eb46708ec74 100644 --- a/apps/server/src/provider/Layers/CliProvider.ts +++ b/apps/server/src/provider/Layers/CliProvider.ts @@ -31,6 +31,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildCliSpawn } from "../../ru-fork/spawn/policy.ts"; import { APP_NAME } from "@ru-fork/branding"; import { CLI_BINARY_NAME, CLI_NAME } from "../../config.ts"; +import { CLI_VERSION_PROBE_TIMEOUT_MS } from "../../timeouts.ts"; +import { haltOnExit } from "../../ru-fork/spawn/haltOnExit.ts"; import { buildServerProvider, collectStreamAsString, @@ -40,8 +42,6 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; -const VERSION_TIMEOUT_MS = 8_000; - const CLI_PRESENTATION = { displayName: CLI_NAME, showInteractionModeToggle: true, @@ -157,8 +157,8 @@ const runCliVersionCommand = ( const child = yield* spawner.spawn(command); const [stdout, stderr, exitCode] = yield* Effect.all( [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), + collectStreamAsString(child.stdout.pipe(haltOnExit(child.exitCode))), + collectStreamAsString(child.stderr.pipe(haltOnExit(child.exitCode))), child.exitCode.pipe(Effect.map(Number)), ], { concurrency: "unbounded" }, @@ -191,7 +191,7 @@ export const checkCliProviderStatus = Effect.fn("checkCliProviderStatus")(functi } const versionProbe = yield* runCliVersionCommand(cliJs, environment).pipe( - Effect.timeoutOption(VERSION_TIMEOUT_MS), + Effect.timeoutOption(CLI_VERSION_PROBE_TIMEOUT_MS), Effect.result, ); @@ -216,13 +216,13 @@ export const checkCliProviderStatus = Effect.fn("checkCliProviderStatus")(functi if (Option.isNone(versionProbe.success)) { // ru-fork: cold-start of cli-code (Node ESM + heavy import graph) - // regularly approaches the 8 s budget. Treat a slow `--version` as a + // regularly approaches the 3 s budget. Treat a slow `--version` as a // soft signal — the binary is installed (spawn succeeded), only the // version string is unknown. Log to server, keep the provider usable. // The previous `status: "error"` + UI message are commented below in // case we want to surface this again later. yield* Effect.logWarning("cli-version-probe-timeout", { - timeoutMs: VERSION_TIMEOUT_MS, + timeoutMs: CLI_VERSION_PROBE_TIMEOUT_MS, }); return buildServerProvider({ presentation: CLI_PRESENTATION, diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 7f5e9d94dc5..bfa34fe4f16 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -12,8 +12,9 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { PROVIDER_LATEST_VERSION_TIMEOUT_MS } from "../timeouts.ts"; + const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; -const LATEST_VERSION_TIMEOUT_MS = 4_000; const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; export interface ProviderMaintenanceCapabilities { @@ -405,7 +406,7 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, ).pipe(HttpClientRequest.setHeader("accept", "application/json")); const response = yield* client.execute(request).pipe( - Effect.timeoutOption(LATEST_VERSION_TIMEOUT_MS), + Effect.timeoutOption(PROVIDER_LATEST_VERSION_TIMEOUT_MS), Effect.catch(() => Effect.succeed(Option.none())), ); if (Option.isNone(response)) { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 0940986568b..d08155d69d4 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -29,9 +29,10 @@ import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; // bash-route on opt-in. See ru-fork/spawn/policy.ts. import { resolveSpawn } from "../ru-fork/spawn/policy.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import { haltOnExit } from "../ru-fork/spawn/haltOnExit.ts"; +import { PROVIDER_UPDATE_TIMEOUT_MS } from "../timeouts.ts"; const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); -const UPDATE_TIMEOUT_MS = 5 * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; export interface ProviderMaintenanceCommandResult { @@ -103,11 +104,11 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR const [stdout, stderr, exitCode] = yield* Effect.all( [ collectUint8StreamText({ - stream: child.stdout, + stream: child.stdout.pipe(haltOnExit(child.exitCode)), maxBytes: UPDATE_OUTPUT_MAX_BYTES, }), collectUint8StreamText({ - stream: child.stderr, + stream: child.stderr.pipe(haltOnExit(child.exitCode)), maxBytes: UPDATE_OUTPUT_MAX_BYTES, }), child.exitCode, @@ -136,7 +137,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR return yield* collectCommandResult().pipe( Effect.scoped, - Effect.timeoutOption(Duration.millis(UPDATE_TIMEOUT_MS)), + Effect.timeoutOption(Duration.millis(PROVIDER_UPDATE_TIMEOUT_MS)), Effect.map((result) => Option.match(result, { onSome: (value) => value, diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index c40903e1b45..086005988d3 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -11,15 +11,16 @@ import type { import * as Effect from "effect/Effect"; import * as Data from "effect/Data"; import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +// ru-fork: only used by the commented-out spawnAndCollect below. +// import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { normalizeModelSlug } from "@t3tools/shared/model"; -import { isWindowsCommandNotFound } from "../processRunner.ts"; +// import { isWindowsCommandNotFound } from "../processRunner.ts"; import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; -export const DEFAULT_TIMEOUT_MS = 4_000; -// Auth status checks involve disk/network lookups and can be slow on first run (especially Windows) -export const AUTH_PROBE_TIMEOUT_MS = 10_000; +// ru-fork: dead — no consumers in the repo. Not relocated to timeouts.ts. +// export const DEFAULT_TIMEOUT_MS = 4_000; +// export const AUTH_PROBE_TIMEOUT_MS = 10_000; export interface CommandResult { readonly stdout: string; @@ -60,25 +61,26 @@ export function isCommandMissingCause(error: { readonly message: string }): bool return lower.includes("enoent") || lower.includes("notfound"); } -export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Command) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner.spawn(command); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - - const result: CommandResult = { stdout, stderr, code: exitCode }; - if (isWindowsCommandNotFound(exitCode, stderr)) { - return yield* new ProviderCommandExecutionError({ message: `spawn ${binaryPath} ENOENT` }); - } - return result; - }).pipe(Effect.scoped); +// ru-fork: no callers in the repo; kept for reference, not exit-gated. +// export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Command) => +// Effect.gen(function* () { +// const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; +// const child = yield* spawner.spawn(command); +// const [stdout, stderr, exitCode] = yield* Effect.all( +// [ +// collectStreamAsString(child.stdout), +// collectStreamAsString(child.stderr), +// child.exitCode.pipe(Effect.map(Number)), +// ], +// { concurrency: "unbounded" }, +// ); +// +// const result: CommandResult = { stdout, stderr, code: exitCode }; +// if (isWindowsCommandNotFound(exitCode, stderr)) { +// return yield* new ProviderCommandExecutionError({ message: `spawn ${binaryPath} ENOENT` }); +// } +// return result; +// }).pipe(Effect.scoped); export function detailFromResult( result: CommandResult & { readonly timedOut?: boolean }, diff --git a/apps/server/src/ru-fork/preflight/common/checks.ts b/apps/server/src/ru-fork/preflight/common/checks.ts index cbb231d714d..afdb3ea45fb 100644 --- a/apps/server/src/ru-fork/preflight/common/checks.ts +++ b/apps/server/src/ru-fork/preflight/common/checks.ts @@ -8,7 +8,7 @@ import { NODE_ENGINE_RANGE, } from "./constants.ts"; import { MESSAGES } from "./messages.ts"; -import { probeVersion } from "./probe.ts"; +import { probeVersion, probeVersionByExit } from "./probe.ts"; import { render } from "./render.ts"; import type { CheckResult } from "./types.ts"; import { isAtLeast, satisfiesRange } from "./version.ts"; @@ -35,11 +35,23 @@ export const checkGit = (): CheckResult => { }; /** Probe the resolved cli.js directly with the running node interpreter. */ -export const checkCli = (cliJs: string): CheckResult => { - const cliProbe = probeVersion(process.execPath, [cliJs, "--version"], CLI_PROBE_TIMEOUT_MS); - if (!cliProbe.ok) return { ok: false, line: MESSAGES.CLI_BROKEN }; - if (CLI_MIN_VERSION && !isAtLeast(cliProbe.version, CLI_MIN_VERSION)) { - return { ok: false, line: render(MESSAGES.CLI_LOW, { found: cliProbe.version }) }; +export const checkCli = async (cliJs: string): Promise => { + const cliProbe = await probeVersionByExit( + process.execPath, + [cliJs, "--version"], + CLI_PROBE_TIMEOUT_MS, + ); + if (cliProbe.ok) { + if (CLI_MIN_VERSION && !isAtLeast(cliProbe.version, CLI_MIN_VERSION)) { + return { ok: false, line: render(MESSAGES.CLI_LOW, { found: cliProbe.version }) }; + } + return { ok: true, line: render(MESSAGES.CLI_OK, { found: cliProbe.version }) }; } - return { ok: true, line: render(MESSAGES.CLI_OK, { found: cliProbe.version }) }; + if (cliProbe.reason !== "timeout") { + return { ok: false, line: MESSAGES.CLI_BROKEN }; + } + // ru-fork: timeout = the process is too slow to EXIT. Held-pipe machines exit + // fast (probeVersionByExit measures the "exit" event) and pass; only genuinely + // slow-to-exit hardware reaches here. Gate the app off it. + return { ok: false, line: MESSAGES.CLI_TOO_SLOW }; }; diff --git a/apps/server/src/ru-fork/preflight/common/constants.ts b/apps/server/src/ru-fork/preflight/common/constants.ts index ba79284dd2a..64298fead54 100644 --- a/apps/server/src/ru-fork/preflight/common/constants.ts +++ b/apps/server/src/ru-fork/preflight/common/constants.ts @@ -17,6 +17,10 @@ export const NODE_ENGINE_RANGE = "^22.16 || ^23.11 || >=24.10"; /** Minimum CLI version; "" disables the version check (presence only). */ export const CLI_MIN_VERSION = "0.13.1"; -export const NODE_PROBE_TIMEOUT_MS = 5_000; -export const CLI_PROBE_TIMEOUT_MS = 15_000; -export const GIT_PROBE_TIMEOUT_MS = 5_000; +// ru-fork: this is a standalone, import-free preflight bundle — it CANNOT import +// apps/server/src/timeouts.ts, so these probe budgets are DUPLICATED there and +// must be kept in sync by hand: +// CLI_PROBE_TIMEOUT_MS ↔ timeouts.ts CLI_VERSION_PROBE_TIMEOUT_MS +// GIT_PROBE_TIMEOUT_MS ↔ timeouts.ts SOURCE_CONTROL_VERSION_PROBE_TIMEOUT_MS +export const CLI_PROBE_TIMEOUT_MS = 3_000; +export const GIT_PROBE_TIMEOUT_MS = 2_000; diff --git a/apps/server/src/ru-fork/preflight/common/messages.ts b/apps/server/src/ru-fork/preflight/common/messages.ts index ad90b83426d..bfc097b1352 100644 --- a/apps/server/src/ru-fork/preflight/common/messages.ts +++ b/apps/server/src/ru-fork/preflight/common/messages.ts @@ -1,6 +1,6 @@ // User-facing Russian strings. `{x}` placeholders are filled via render.ts. -import { CLI_NPM_PACKAGE } from "@ru-fork/branding"; +import { CLI_NAME, CLI_NPM_PACKAGE } from "@ru-fork/branding"; import { CLI_MIN_VERSION, NODE_ENGINE_RANGE } from "./constants.ts"; export const MESSAGES = { @@ -17,7 +17,8 @@ export const MESSAGES = { CLI_OK: "CLI {found} ✓", CLI_BROKEN: "CLI установлен, но `cli.js --version` завершилась с ошибкой или превысила тайм-аут.", CLI_LOW: `CLI {found} установлен, требуется ≥ ${CLI_MIN_VERSION}. Обновите: npm install -g ${CLI_NPM_PACKAGE}@latest`, - FOOTER_FAIL: "Установите недостающие компоненты и перезапустите.", + CLI_TOO_SLOW: `${CLI_NAME} работает слишком медленно на данном оборудовании, приложение будет работать не стабильно.`, + FOOTER_FAIL: "Устраните указанные выше проблемы и перезапустите.", USING_BACKUP_PRIORITY: "Используется резервный CLI (TRY_TO_FIND_CLI имеет приоритет над основным).", } as const; diff --git a/apps/server/src/ru-fork/preflight/common/probe.ts b/apps/server/src/ru-fork/preflight/common/probe.ts index 89ae4ec720d..a2b85fba7f8 100644 --- a/apps/server/src/ru-fork/preflight/common/probe.ts +++ b/apps/server/src/ru-fork/preflight/common/probe.ts @@ -1,11 +1,12 @@ // @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalTimers:off // Preflight is deliberately Effect-free + node-builtins-only so it bundles into // one standalone file (dist/preflight.mjs) that runs before any deps exist. // // The only subprocess in the resolver: ` ` with a timeout, used // for node/git/cli version probes. Distinguishes missing / broken / timeout. -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { extractVersion } from "./version.ts"; import type { ProbeResult } from "./types.ts"; @@ -35,3 +36,55 @@ export const probeVersion = ( const version = extractVersion(combinedOutput); return version ? { ok: true, version } : { ok: false, reason: "broken" }; }; + +// ru-fork: measure PROCESS-EXIT time (the "exit" event), NOT pipe close. A child +// that exits but whose stdout is held open (AV / daemon) still resolves here +// fast; only a process genuinely slow to exit trips the timeout. +export const probeVersionByExit = ( + command: string, + args: ReadonlyArray, + timeoutMs: number, +): Promise => + new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let settled = false; + let child: ReturnType | undefined; + const finish = (result: ProbeResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + const timer = setTimeout(() => { + try { + child?.kill(); + } catch { + /* already gone */ + } + finish({ ok: false, reason: "timeout" }); + }, timeoutMs); + try { + const c = spawn(command, [...args], { windowsHide: true }); + child = c; + c.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + c.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + c.on("error", (error: NodeJS.ErrnoException) => { + finish({ ok: false, reason: error.code === "ENOENT" ? "missing" : "broken" }); + }); + c.on("exit", (status: number | null) => { + if (status !== 0) { + finish({ ok: false, reason: "broken" }); + return; + } + const version = extractVersion(`${stdout}${stderr}`); + finish(version ? { ok: true, version } : { ok: false, reason: "broken" }); + }); + } catch { + finish({ ok: false, reason: "broken" }); + } + }); diff --git a/apps/server/src/ru-fork/preflight/preflight-install.ts b/apps/server/src/ru-fork/preflight/preflight-install.ts index 26b21901b23..68b17e6ee99 100644 --- a/apps/server/src/ru-fork/preflight/preflight-install.ts +++ b/apps/server/src/ru-fork/preflight/preflight-install.ts @@ -33,7 +33,7 @@ const writeError = (line: string): void => const exitFailure = (): never => process.exit(1); -const main = (): void => { +const main = async (): Promise => { // Diagnostics first — so even an early STOP carries the full environment. writeInfo("Диагностика:"); for (const line of collectDiagnostics()) writeInfo(` ${line}`); @@ -81,7 +81,7 @@ const main = (): void => { // the failure reads as "node", not a confusing cli error. (nodeCheck computed // above for NODE_OK.) const cliCheck: CheckResult = nodeCheck.ok - ? checkCli(cliJs) + ? await checkCli(cliJs) : { ok: false, line: "CLI: пропущено — Node.js не соответствует требованиям" }; const gitCheck = checkGit(); @@ -98,4 +98,4 @@ const main = (): void => { } }; -main(); +void main(); diff --git a/apps/server/src/ru-fork/preflight/preflight-startup.ts b/apps/server/src/ru-fork/preflight/preflight-startup.ts index dd5ceaff6ef..b900fd377cd 100644 --- a/apps/server/src/ru-fork/preflight/preflight-startup.ts +++ b/apps/server/src/ru-fork/preflight/preflight-startup.ts @@ -94,7 +94,7 @@ export const resolveStartupCli: Effect.Effect => Effect.gen(function* () { - const results = [checkNodeEngine(), checkGit(), checkCli(cliJs)]; + const results = [checkNodeEngine(), checkGit(), yield* Effect.promise(() => checkCli(cliJs))]; for (const result of results) { if (result.ok) yield* Effect.logInfo(result.line); else yield* Effect.logError(result.line); diff --git a/apps/server/src/ru-fork/spawn/haltOnExit.ts b/apps/server/src/ru-fork/spawn/haltOnExit.ts new file mode 100644 index 00000000000..efb73bc0dc8 --- /dev/null +++ b/apps/server/src/ru-fork/spawn/haltOnExit.ts @@ -0,0 +1,24 @@ +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import { EXIT_DRAIN_GRACE_MS } from "../../timeouts.ts"; + +// ru-fork: end a child's stdio stream when the PROCESS EXITS (+ a short drain), +// instead of waiting for stdio EOF — which corporate AV / a git daemon / a stuck +// async `end` can hold open. exitCode resolves on the Node "exit" event, +// independent of the pipe (verified: probe-interruptwhen.mjs). On a healthy +// machine EOF ends the stream first → zero added latency, identical output. +export const haltOnExit = + (exitCode: Effect.Effect) => + (stream: Stream.Stream): Stream.Stream => + stream.pipe( + Stream.interruptWhen( + // Effect.ignore: settle on exit success OR failure (we only want the + // timing); never let the signal fail the stream. + exitCode.pipe( + Effect.ignore, + Effect.flatMap(() => Effect.sleep(Duration.millis(EXIT_DRAIN_GRACE_MS))), + ), + ), + ); diff --git a/apps/server/src/ru-fork/terminal/logTerminalStartFailure.ts b/apps/server/src/ru-fork/terminal/logTerminalStartFailure.ts new file mode 100644 index 00000000000..1c33ec52026 --- /dev/null +++ b/apps/server/src/ru-fork/terminal/logTerminalStartFailure.ts @@ -0,0 +1,35 @@ +// ru-fork: log terminal open()/restart() failures to the server at ERROR level. +// +// Upstream's RPC observability wrapper (`observeRpcEffect` in `ws.ts`) was +// stripped to a no-op when telemetry was removed, so a failed terminal open was +// logged NOWHERE — the real reason (bad cwd, unreadable history, a defect) was +// invisible on the server while the UI showed a generic "Failed to open +// terminal". This pipe-able transform taps the failure cause, logs the real +// reason once, and RE-RAISES it unchanged — the typed `TerminalError` still +// propagates, so every caller (the run-a-command callers, the RPC handler, the +// drawer) keeps its existing error handling. A cancelled request +// (interrupt-only cause) is not logged as an error. + +import { DEFAULT_TERMINAL_ID } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; + +export const logTerminalStartFailure = + (input: { + readonly threadId: string; + readonly terminalId?: string | undefined; + readonly cwd: string; + }) => + (work: Effect.Effect): Effect.Effect => + work.pipe( + Effect.tapCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logError("terminal start failed", { + threadId: input.threadId, + terminalId: input.terminalId ?? DEFAULT_TERMINAL_ID, + cwd: input.cwd, + cause: Cause.pretty(cause), + }), + ), + ); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 14d01aab2ed..b2ffc97f4d7 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -13,8 +13,7 @@ import { import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubPullRequests from "./gitHubPullRequests.ts"; - -const DEFAULT_TIMEOUT_MS = 30_000; +import { SOURCE_CONTROL_DEFAULT_TIMEOUT_MS } from "../timeouts.ts"; export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", { operation: Schema.String, @@ -236,7 +235,7 @@ export const make = Effect.fn("makeGitHubCli")(function* () { command: "gh", args: input.args, cwd: input.cwd, - timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + timeoutMs: input.timeoutMs ?? SOURCE_CONTROL_DEFAULT_TIMEOUT_MS, }) .pipe(Effect.mapError((error) => normalizeGitHubCliError("execute", error))); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index 8aa07fa5f8c..12921502ac5 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -13,6 +13,10 @@ import * as Option from "effect/Option"; import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { + SOURCE_CONTROL_AUTH_PROBE_TIMEOUT_MS, + SOURCE_CONTROL_VERSION_PROBE_TIMEOUT_MS, +} from "../timeouts.ts"; interface DiscoveryProbe { readonly label: string; @@ -324,7 +328,7 @@ export const layer = Layer.effect( command: input.executable, args: input.versionArgs, cwd: config.cwd, - timeoutMs: 5_000, + timeoutMs: SOURCE_CONTROL_VERSION_PROBE_TIMEOUT_MS, maxOutputBytes: 8_000, truncateOutputAtMaxBytes: true, }) @@ -375,7 +379,7 @@ export const layer = Layer.effect( args: input.authArgs, cwd: config.cwd, allowNonZeroExit: true, - timeoutMs: 5_000, + timeoutMs: SOURCE_CONTROL_AUTH_PROBE_TIMEOUT_MS, maxOutputBytes: 8_000, truncateOutputAtMaxBytes: true, }) diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index 425c4f61778..766bebb3ced 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -7,6 +7,10 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import type * as VcsProcess from "../vcs/VcsProcess.ts"; +import { + SOURCE_CONTROL_AUTH_PROBE_TIMEOUT_MS, + SOURCE_CONTROL_VERSION_PROBE_TIMEOUT_MS, +} from "../timeouts.ts"; export interface SourceControlAuthProbeInput { readonly stdout: string; @@ -137,7 +141,7 @@ function probeCli(input: { command: input.spec.executable, args: input.spec.versionArgs, cwd: input.cwd, - timeoutMs: 5_000, + timeoutMs: SOURCE_CONTROL_VERSION_PROBE_TIMEOUT_MS, maxOutputBytes: 8_000, truncateOutputAtMaxBytes: true, }) @@ -214,7 +218,7 @@ export function probeSourceControlProvider(input: { args: spec.authArgs, cwd: input.cwd, allowNonZeroExit: true, - timeoutMs: 5_000, + timeoutMs: SOURCE_CONTROL_AUTH_PROBE_TIMEOUT_MS, maxOutputBytes: 8_000, truncateOutputAtMaxBytes: true, }) diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index f1ee1940390..a7e3d685d8c 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -20,6 +20,7 @@ import { } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; +import { SOURCE_CONTROL_REPO_OP_TIMEOUT_MS } from "../timeouts.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); @@ -235,7 +236,7 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () operation: "SourceControlRepositoryService.cloneRepository", cwd: preparedDestination.parentPath, args: ["clone", remoteUrl, preparedDestination.directoryName], - timeoutMs: 120_000, + timeoutMs: SOURCE_CONTROL_REPO_OP_TIMEOUT_MS, maxOutputBytes: 256 * 1024, }); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 8585aca30e1..33680cfbd07 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -21,12 +21,20 @@ import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; import { ServerConfig } from "../../config.ts"; +// ru-fork: log terminal start failures to the server (the RPC observability +// seam is a no-op since telemetry was removed). See the module for details. +import { logTerminalStartFailure } from "../../ru-fork/terminal/logTerminalStartFailure.ts"; import { increment, terminalRestartsTotal, terminalSessionsTotal, } from "../../observability/Metrics.ts"; import * as ProcessRunner from "../../processRunner.ts"; +import { + TERMINAL_BUSY_CHECK_TIMEOUT_MS, + TERMINAL_KILL_GRACE_MS, + TERMINAL_SUBPROCESS_CHECK_TIMEOUT_MS, +} from "../../timeouts.ts"; import { TerminalCwdError, TerminalHistoryError, @@ -46,7 +54,6 @@ import { const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; -const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; const DEFAULT_OPEN_COLS = 120; const DEFAULT_OPEN_ROWS = 30; @@ -397,7 +404,7 @@ const checkWindowsSubprocessActivity = Effect.fn("terminal.checkWindowsSubproces .run({ command: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command", command], - timeout: 1_500, + timeout: TERMINAL_BUSY_CHECK_TIMEOUT_MS, maxOutputBytes: 32_768, outputMode: "truncate", timeoutBehavior: "timedOutResult", @@ -425,7 +432,7 @@ const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessAct .run({ command: "pgrep", args: ["-P", String(terminalPid)], - timeout: 1_000, + timeout: TERMINAL_SUBPROCESS_CHECK_TIMEOUT_MS, maxOutputBytes: 32_768, outputMode: "truncate", timeoutBehavior: "timedOutResult", @@ -446,7 +453,7 @@ const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessAct .run({ command: "ps", args: ["-eo", "pid=,ppid="], - timeout: 1_000, + timeout: TERMINAL_SUBPROCESS_CHECK_TIMEOUT_MS, maxOutputBytes: 262_144, outputMode: "truncate", timeoutBehavior: "timedOutResult", @@ -780,7 +787,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; const subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; - const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; + const processKillGraceMs = options.processKillGraceMs ?? TERMINAL_KILL_GRACE_MS; const maxRetainedInactiveSessions = options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; @@ -1803,7 +1810,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith } return snapshot(liveSession); - }), + }).pipe(logTerminalStartFailure(input)), ); const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) { @@ -1935,7 +1942,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith "restarted", ); return snapshot(session); - }), + }).pipe(logTerminalStartFailure(input)), ); const close: TerminalManagerShape["close"] = (input) => diff --git a/apps/server/src/textGeneration/CliTextGeneration.ts b/apps/server/src/textGeneration/CliTextGeneration.ts index dc600e8eaef..73c864d3259 100644 --- a/apps/server/src/textGeneration/CliTextGeneration.ts +++ b/apps/server/src/textGeneration/CliTextGeneration.ts @@ -27,6 +27,8 @@ import { TextGenerationError } from "@t3tools/contracts"; // ru-fork: CLI is launched as `node …` directly. See // ru-fork/spawn/policy.ts buildCliSpawn. import { buildCliSpawn } from "../ru-fork/spawn/policy.ts"; +import { CLI_TEXT_GENERATION_TIMEOUT_MS } from "../timeouts.ts"; +import { haltOnExit } from "../ru-fork/spawn/haltOnExit.ts"; import { expandHomePath } from "../pathExpansion.ts"; import { type TextGenerationShape } from "./TextGeneration.ts"; import { @@ -67,8 +69,6 @@ const buildCliSingleStringPrompt = (input: { return sections.join("\n"); }; -const CLI_TIMEOUT_MS = 180_000; - // Precompiled JSON decoders for Cli's structured-output operations. Hoisted to // module scope so neither the schema literal nor the compiled decoder is // rebuilt per call (see oxlint t3code/no-inline-schema-compile and the same @@ -173,8 +173,8 @@ export const makeCliTextGeneration = Effect.fn("makeCliTextGeneration")(function const [stdout, stderr, exitCode] = yield* Effect.all( [ - readStreamAsString(input.operation, child.stdout), - readStreamAsString(input.operation, child.stderr), + readStreamAsString(input.operation, child.stdout.pipe(haltOnExit(child.exitCode))), + readStreamAsString(input.operation, child.stderr.pipe(haltOnExit(child.exitCode))), child.exitCode.pipe( Effect.mapError((cause) => normalizeCliError(CLI_NAME, input.operation, cause, "Failed to read Cli CLI exit code"), @@ -203,7 +203,7 @@ export const makeCliTextGeneration = Effect.fn("makeCliTextGeneration")(function const runCliWithTimeout = (input: { operation: CliOperation; cwd: string; prompt: string }) => runCliCommand(input).pipe( Effect.scoped, - Effect.timeoutOption(CLI_TIMEOUT_MS), + Effect.timeoutOption(CLI_TEXT_GENERATION_TIMEOUT_MS), Effect.flatMap( Option.match({ onNone: () => diff --git a/apps/server/src/timeouts.ts b/apps/server/src/timeouts.ts new file mode 100644 index 00000000000..12592930a79 --- /dev/null +++ b/apps/server/src/timeouts.ts @@ -0,0 +1,81 @@ +// SINGLE SOURCE OF TRUTH for every server-side timeout. All values in ms. +// ru-fork: no hardcoded timeout literal anywhere else in apps/server/src +// (intervals/TTLs are a different concept and stay where they are). +// Preflight keeps its own ru-fork/preflight/common/constants.ts (standalone bundle). + +// subprocess collect (the exit-gated-collect fix) +export const EXIT_DRAIN_GRACE_MS = 250; + +export const PROCESS_RUNNER_DEFAULT_TIMEOUT_MS = 60_000; + +// CLI provider / text-gen / provider maintenance +// ru-fork: KEEP IN SYNC with ru-fork/preflight/common/constants.ts → +// CLI_PROBE_TIMEOUT_MS. Same ` --version` probe; preflight is a standalone +// import-free bundle, so the value is duplicated there, not shared. +export const CLI_VERSION_PROBE_TIMEOUT_MS = 3_000; + +export const CLI_TEXT_GENERATION_TIMEOUT_MS = 180_000; +export const PROVIDER_UPDATE_TIMEOUT_MS = 300_000; +export const PROVIDER_LATEST_VERSION_TIMEOUT_MS = 4_000; // npm-registry "latest" fetch + +// git (by current value — behavior-preserving tiers) +export const GIT_TIMEOUT_FAST_MS = 5_000; +export const GIT_TIMEOUT_STANDARD_MS = 10_000; +export const GIT_TIMEOUT_FETCH_MS = 15_000; +export const GIT_TIMEOUT_NETWORK_MS = 20_000; +export const GIT_TIMEOUT_DEFAULT_MS = 30_000; +export const GIT_COMMIT_TIMEOUT_MS = 600_000; +export const GIT_STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 15_000; + +// source control +export const SOURCE_CONTROL_DEFAULT_TIMEOUT_MS = 30_000; +// ru-fork: SOURCE_CONTROL_VERSION_PROBE_TIMEOUT_MS — KEEP IN SYNC with +// ru-fork/preflight/common/constants.ts → GIT_PROBE_TIMEOUT_MS. Same ` +// --version` probe; preflight duplicates the value (can't import this file). +export const SOURCE_CONTROL_VERSION_PROBE_TIMEOUT_MS = 2_000; // ` --version` (git/gh/…), local +export const SOURCE_CONTROL_AUTH_PROBE_TIMEOUT_MS = 5_000; // ` auth status`, network +export const SOURCE_CONTROL_REPO_OP_TIMEOUT_MS = 120_000; + +// terminal +export const TERMINAL_BUSY_CHECK_TIMEOUT_MS = 1_500; +export const TERMINAL_SUBPROCESS_CHECK_TIMEOUT_MS = 1_000; +export const TERMINAL_KILL_GRACE_MS = 1_000; + +// daemon / CLI live-server probe +export const DAEMON_HEALTH_PROBE_TIMEOUT_MS = 1_000; +export const DAEMON_SPAWN_TIMEOUT_MS = 5_000; +export const PROJECT_CLI_LIVE_SERVER_TIMEOUT_MS = 1_000; + +// ACP wire-stall / post-answer resume (moved verbatim from config.ts). +/** + * ACP_WIRE_STALL_WARN_MS — soft threshold for "CLI seems stuck" + * detection. While a turn is active, the adapter watches incoming + * JSON-RPC frames on the ACP wire (every notification, response, or + * frame from CLI). If no frame arrives for this many milliseconds, + * a WARN log is emitted once per stall. No teardown — just visibility. + */ +export const ACP_WIRE_STALL_WARN_MS = 3_600_000; + +/** + * ACP_WIRE_STALL_KILL_MS — hard threshold. After this much wire + * silence during an active turn the adapter declares the session + * stuck and runs `abortSession(ctx, "end-force")`. The next user + * message starts a fresh CLI child and `session/load`s the prior + * transcript via the persisted resumeCursor. Backstop so a stuck + * agent can never strand the chat indefinitely. + */ +export const ACP_WIRE_STALL_KILL_MS = 7_200_000; + +/** + * POST_ANSWER_RESUME_TIMEOUT_MS — one-shot timeout used by the + * post-answer resume probe (`armPostAnswerResumeProbe` in + * `AcpPendingRequests.ts`). After the adapter resolves a pending + * user-input / approval / plan-approval Deferred, a fiber sleeps this + * long and then checks `ctx.wireActivity.lastIncomingAt`. If CLI has + * not produced a single inbound frame in that window, the session is + * declared wedged and `abortSession(ctx, MAINTENANCE_METHOD)` is + * called. Probe measurement showed the typical resume latency is + * ~4 ms, so 10 s is ~2500× margin — generous but tight enough to + * recover snappily. + */ +export const POST_ANSWER_RESUME_TIMEOUT_MS = 3_600_000; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 2bd2d5c00da..8d788b360eb 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -25,6 +25,11 @@ import { import * as GitVcsDriverCore from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; +import { + GIT_TIMEOUT_FAST_MS, + GIT_TIMEOUT_NETWORK_MS, + GIT_TIMEOUT_STANDARD_MS, +} from "../timeouts.ts"; export interface ExecuteGitInput { readonly operation: string; @@ -357,7 +362,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ["rev-parse", "--is-inside-work-tree"], { allowNonZeroExit: true, - timeoutMs: 5_000, + timeoutMs: GIT_TIMEOUT_FAST_MS, maxOutputBytes: 4_096, }, ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true")); @@ -415,7 +420,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ], { allowNonZeroExit: true, - timeoutMs: 20_000, + timeoutMs: GIT_TIMEOUT_NETWORK_MS, maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, truncateOutputAtMaxBytes: true, }, @@ -446,7 +451,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( function* (cwd) { const result = yield* gitCommand(process, "GitVcsDriver.listRemotes", cwd, ["remote", "-v"], { allowNonZeroExit: true, - timeoutMs: 5_000, + timeoutMs: GIT_TIMEOUT_FAST_MS, maxOutputBytes: 64 * 1024, }); @@ -501,7 +506,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( { stdin: `${chunk.join("\0")}\0`, allowNonZeroExit: true, - timeoutMs: 20_000, + timeoutMs: GIT_TIMEOUT_NETWORK_MS, maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, truncateOutputAtMaxBytes: true, }, @@ -531,7 +536,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const initRepository: VcsDriver.VcsDriverShape["initRepository"] = (input) => gitCommand(process, "GitVcsDriver.initRepository", input.cwd, ["init"], { - timeoutMs: 10_000, + timeoutMs: GIT_TIMEOUT_STANDARD_MS, maxOutputBytes: 64 * 1024, }).pipe(Effect.asVoid); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 2c7ed73013d..a65c12af57e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -31,6 +31,7 @@ import { // resolved shell value stays false unless --windows-use-bash-for // explicitly lists "git". See ru-fork/spawn/policy.ts. import { resolveSpawn } from "../ru-fork/spawn/policy.ts"; +import { haltOnExit } from "../ru-fork/spawn/haltOnExit.ts"; // ru-fork: exclude Windows reserved-name junk (e.g. a stray `nul`) from // staging so it can't abort `git add -A`. See git-issues.md. import { WINDOWS_RESERVED_EXCLUDES } from "../ru-fork/vcs/reservedNames.ts"; @@ -38,9 +39,15 @@ import { ServerConfig } from "../config.ts"; // ru-fork: needed so `refreshStatusUpstreamIfStale` can short-circuit when // the user sets `automaticGitFetchInterval` to 0. import { ServerSettingsService } from "../serverSettings.ts"; +import { + GIT_STATUS_UPSTREAM_REFRESH_TIMEOUT_MS, + GIT_TIMEOUT_DEFAULT_MS, + GIT_TIMEOUT_FAST_MS, + GIT_TIMEOUT_FETCH_MS, + GIT_TIMEOUT_STANDARD_MS, +} from "../timeouts.ts"; const isGitCommandError = Schema.is(GitCommandError); -const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; @@ -48,9 +55,6 @@ const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); -// ru-fork: 5s was too tight — proxy HTTPS fetch can legitimately -// exceed that on the first round trip. 15s gives the network real headroom. -const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(15); // ru-fork: on failure, back off for 15s (matching the success cadence) // instead of 5s so we don't hot-loop and spam the log every few seconds. const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(15); @@ -634,7 +638,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...input, args: [...input.args], } as const; - const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const timeoutMs = input.timeoutMs ?? GIT_TIMEOUT_DEFAULT_MS; const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false; @@ -666,14 +670,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* [ collectOutput( commandInput, - child.stdout, + child.stdout.pipe(haltOnExit(child.exitCode)), maxOutputBytes, truncateOutputAtMaxBytes, input.progress?.onStdoutLine, ), collectOutput( commandInput, - child.stderr, + child.stderr.pipe(haltOnExit(child.exitCode)), maxOutputBytes, truncateOutputAtMaxBytes, input.progress?.onStderrLine, @@ -834,7 +838,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["show-ref", "--verify", "--quiet", `refs/heads/${refName}`], { allowNonZeroExit: true, - timeoutMs: 5_000, + timeoutMs: GIT_TIMEOUT_FAST_MS, }, ).pipe(Effect.map((result) => result.exitCode === 0)); @@ -898,7 +902,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { allowNonZeroExit: true, env: STATUS_UPSTREAM_REFRESH_ENV, - timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), + timeoutMs: GIT_STATUS_UPSTREAM_REFRESH_TIMEOUT_MS, }, ).pipe(Effect.asVoid); }; @@ -1178,7 +1182,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "refs/remotes", ], { - timeoutMs: 15_000, + timeoutMs: GIT_TIMEOUT_FETCH_MS, allowNonZeroExit: true, }, ); @@ -1621,7 +1625,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* true, ).pipe(Effect.map((stdout) => stdout.trim())); yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { - timeoutMs: 30_000, + timeoutMs: GIT_TIMEOUT_DEFAULT_MS, fallbackErrorMessage: "git pull failed", }); const afterSha = yield* runGitStdout( @@ -1699,7 +1703,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.cwd, ["branch", "--no-color", "--no-column"], { - timeoutMs: 10_000, + timeoutMs: GIT_TIMEOUT_STANDARD_MS, allowNonZeroExit: true, }, ).pipe( @@ -1738,7 +1742,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.cwd, ["branch", "--no-color", "--no-column", "--remotes"], { - timeoutMs: 10_000, + timeoutMs: GIT_TIMEOUT_STANDARD_MS, allowNonZeroExit: true, }, ).pipe( @@ -1762,7 +1766,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.cwd, ["remote"], { - timeoutMs: 5_000, + timeoutMs: GIT_TIMEOUT_FAST_MS, allowNonZeroExit: true, }, ).pipe( @@ -1789,7 +1793,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], { - timeoutMs: 5_000, + timeoutMs: GIT_TIMEOUT_FAST_MS, allowNonZeroExit: true, }, ), @@ -1798,7 +1802,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.cwd, ["worktree", "list", "--porcelain"], { - timeoutMs: 5_000, + timeoutMs: GIT_TIMEOUT_FAST_MS, allowNonZeroExit: true, }, ), @@ -2014,7 +2018,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } args.push(input.path); yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { - timeoutMs: 15_000, + timeoutMs: GIT_TIMEOUT_FETCH_MS, fallbackErrorMessage: "git worktree remove failed", }).pipe( Effect.mapError((error) => @@ -2041,7 +2045,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.cwd, ["branch", "-m", "--", input.oldBranch, targetBranch], { - timeoutMs: 10_000, + timeoutMs: GIT_TIMEOUT_STANDARD_MS, fallbackErrorMessage: "git branch rename failed", }, ); @@ -2059,7 +2063,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], { - timeoutMs: 5_000, + timeoutMs: GIT_TIMEOUT_FAST_MS, allowNonZeroExit: true, }, ).pipe(Effect.map((result) => result.exitCode === 0)), @@ -2068,7 +2072,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.cwd, ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], { - timeoutMs: 5_000, + timeoutMs: GIT_TIMEOUT_FAST_MS, allowNonZeroExit: true, }, ).pipe(Effect.map((result) => result.exitCode === 0)), @@ -2082,7 +2086,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.cwd, ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], { - timeoutMs: 5_000, + timeoutMs: GIT_TIMEOUT_FAST_MS, allowNonZeroExit: true, }, ).pipe( @@ -2102,7 +2106,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], { - timeoutMs: 5_000, + timeoutMs: GIT_TIMEOUT_FAST_MS, allowNonZeroExit: true, }, ).pipe(Effect.map((result) => result.exitCode === 0)) @@ -2119,7 +2123,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : ["checkout", input.refName]; yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { - timeoutMs: 10_000, + timeoutMs: GIT_TIMEOUT_STANDARD_MS, fallbackErrorMessage: "git checkout failed", }); @@ -2135,7 +2139,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const createRef: GitVcsDriver.GitVcsDriverShape["createRef"] = Effect.fn("createRef")( function* (input) { yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], { - timeoutMs: 10_000, + timeoutMs: GIT_TIMEOUT_STANDARD_MS, fallbackErrorMessage: "git branch create failed", }); if (input.switchRef) { @@ -2148,7 +2152,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const initRepo: GitVcsDriver.GitVcsDriverShape["initRepo"] = (input) => executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { - timeoutMs: 10_000, + timeoutMs: GIT_TIMEOUT_STANDARD_MS, fallbackErrorMessage: "git init failed", }).pipe(Effect.asVoid); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index e708b09f216..240d4d41b8f 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -20,6 +20,8 @@ import { // --windows-use-bash-for. See ru-fork/spawn/policy.ts. import { resolveSpawn } from "../ru-fork/spawn/policy.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import { haltOnExit } from "../ru-fork/spawn/haltOnExit.ts"; +import { GIT_TIMEOUT_DEFAULT_MS } from "../timeouts.ts"; export interface VcsProcessInput { readonly operation: string; @@ -68,7 +70,6 @@ export class VcsProcess extends Context.Service()( "t3/vcs/VcsProcess", ) {} -const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; @@ -168,7 +169,7 @@ export const make = Effect.fn("makeVcsProcess")(function* () { Effect.scoped(spawn(input).pipe(Effect.flatMap(use))); const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { - const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const timeoutMs = input.timeoutMs ?? GIT_TIMEOUT_DEFAULT_MS; const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const label = commandLabel(input.command, input.args); @@ -180,7 +181,7 @@ export const make = Effect.fn("makeVcsProcess")(function* () { operation: input.operation, command: label, cwd: input.cwd, - stream: child.stdout, + stream: child.stdout.pipe(haltOnExit(child.exitCode)), maxOutputBytes, truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes ?? false, }), @@ -188,7 +189,7 @@ export const make = Effect.fn("makeVcsProcess")(function* () { operation: input.operation, command: label, cwd: input.cwd, - stream: child.stderr, + stream: child.stderr.pipe(haltOnExit(child.exitCode)), maxOutputBytes, truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes ?? false, }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f95a424170a..2793752150a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -46,27 +46,39 @@ import { Open, resolveAvailableEditors } from "./open.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -// Telemetry/observability removed: keep the call shape but pass through. -// `observeRpcStreamEffect` MUST `Stream.unwrap` the effect — the RPC -// handler signature for streaming methods expects a `Stream`, not an -// `Effect`. Returning the Effect raw breaks every stream -// subscription (subscribeServerConfig, orchestration shell, etc.). +// ru-fork: telemetry/observability (spans + metrics) was removed, and the +// per-RPC FAILURE CAPTURE that lived in this wrapper went with it — so every +// RPC failed silently server-side. These wrappers restore just the failure +// log (no OTel): a real bug (defect/die) → ERROR; an expected typed failure +// or a cancellation → DEBUG, so the error log stays signal-rich. `_attributes` +// is kept for call-shape compatibility. See changes/rpc-failure-logging.md. +// `observeRpcStreamEffect` MUST `Stream.unwrap` the effect — the RPC handler +// signature for streaming methods expects a `Stream`, not an `Effect`. +const logRpcCause = (method: string, cause: Cause.Cause): Effect.Effect => + Cause.hasInterruptsOnly(cause) + ? Effect.logDebug("RPC прерван", { method }) + : Cause.hasDies(cause) + ? Effect.logError("Сбой RPC (defect)", { method, cause: Cause.pretty(cause) }) + : Effect.logDebug("Сбой RPC", { method, cause: Cause.pretty(cause) }); + const observeRpcEffect = ( - _method: string, + method: string, effect: Effect.Effect, _attributes?: Readonly>, -): Effect.Effect => effect; +): Effect.Effect => effect.pipe(Effect.tapCause((cause) => logRpcCause(method, cause))); const observeRpcStream = ( - _method: string, + method: string, stream: Stream.Stream, _attributes?: Readonly>, -): Stream.Stream => stream; +): Stream.Stream => stream.pipe(Stream.tapCause((cause) => logRpcCause(method, cause))); const observeRpcStreamEffect = ( - _method: string, + method: string, effect: Effect.Effect, EffectError, EffectContext>, _attributes?: Readonly>, ): Stream.Stream => - Stream.unwrap(effect); + Stream.unwrap(effect.pipe(Effect.tapCause((cause) => logRpcCause(method, cause)))).pipe( + Stream.tapCause((cause) => logRpcCause(method, cause)), + ); import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; // ru-fork: filesystem skill scanner — owned RPCs (server.listSkillsForCwd, diff --git a/apps/server/tests/haltOnExit.test.ts b/apps/server/tests/haltOnExit.test.ts new file mode 100644 index 00000000000..e5a0f5ebd92 --- /dev/null +++ b/apps/server/tests/haltOnExit.test.ts @@ -0,0 +1,61 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import { haltOnExit } from "../src/ru-fork/spawn/haltOnExit.ts"; + +// A stream that emits a known prefix then blocks forever (Stream.never), +// modelling a child whose pipe never reaches EOF. +const prefixThenHang = Stream.concat(Stream.make("a", "b", "c"), Stream.never); + +it.live("ends on exit + drain and keeps the prefix (runFold)", () => + Effect.gen(function* () { + const out = yield* prefixThenHang.pipe( + haltOnExit(Effect.void), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + assert.strictEqual(out, "abc"); + }), +); + +it.live("works with a mutable accumulator (runForEach)", () => + Effect.gen(function* () { + let acc = ""; + const halted = prefixThenHang.pipe(haltOnExit(Effect.void)); + yield* Stream.runForEach(halted, (chunk) => + Effect.sync(() => { + acc += chunk; + }), + ); + assert.strictEqual(acc, "abc"); + }), +); + +it.live("works with an effectful fold (runFoldEffect)", () => + Effect.gen(function* () { + const out = yield* prefixThenHang.pipe( + haltOnExit(Effect.void), + Stream.runFoldEffect( + () => "", + (acc, chunk) => Effect.succeed(acc + chunk), + ), + ); + assert.strictEqual(out, "abc"); + }), +); + +it.live("a failing exit signal still settles the stream (Effect.ignore)", () => + Effect.gen(function* () { + const out = yield* prefixThenHang.pipe( + haltOnExit(Effect.fail("boom")), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + assert.strictEqual(out, "abc"); + }), +); diff --git a/apps/server/tests/preflightCheckCli.test.ts b/apps/server/tests/preflightCheckCli.test.ts new file mode 100644 index 00000000000..1c6c7189ffd --- /dev/null +++ b/apps/server/tests/preflightCheckCli.test.ts @@ -0,0 +1,45 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { checkCli } from "../src/ru-fork/preflight/common/checks.ts"; +import { MESSAGES } from "../src/ru-fork/preflight/common/messages.ts"; + +let dir: string; +const cli = (name: string): string => join(dir, name); + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "ru-fork-checkcli-")); + writeFileSync(cli("ok.js"), "process.stdout.write('9.9.9')"); // >= CLI_MIN_VERSION + writeFileSync(cli("low.js"), "process.stdout.write('0.1.0')"); // < CLI_MIN_VERSION + writeFileSync(cli("broken.js"), "process.exit(2)"); + writeFileSync(cli("slow.js"), "setTimeout(function(){}, 10000)"); // never exits in time +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("checkCli", () => { + it("ok for a fast cli at/above the minimum version", async () => { + const result = await checkCli(cli("ok.js")); + expect(result.ok).toBe(true); + }); + + it("fails (CLI_LOW) for a version below the minimum", async () => { + const result = await checkCli(cli("low.js")); + expect(result.ok).toBe(false); + }); + + it("fails (CLI_BROKEN) for a non-zero exit", async () => { + const result = await checkCli(cli("broken.js")); + expect(result).toEqual({ ok: false, line: MESSAGES.CLI_BROKEN }); + }); + + it("fails (CLI_TOO_SLOW) when the cli is too slow to exit", async () => { + const result = await checkCli(cli("slow.js")); + expect(result).toEqual({ ok: false, line: MESSAGES.CLI_TOO_SLOW }); + }); +}); diff --git a/apps/server/tests/preflightProbeByExit.test.ts b/apps/server/tests/preflightProbeByExit.test.ts new file mode 100644 index 00000000000..827e59b2ec6 --- /dev/null +++ b/apps/server/tests/preflightProbeByExit.test.ts @@ -0,0 +1,59 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { execPath } from "node:process"; +import { describe, expect, it } from "vitest"; + +import { probeVersionByExit } from "../src/ru-fork/preflight/common/probe.ts"; + +const TIMEOUT = 3_000; + +describe("probeVersionByExit", () => { + it("ok+version when the process exits fast with a version", async () => { + const result = await probeVersionByExit( + execPath, + ["-e", "process.stdout.write('1.2.3')"], + TIMEOUT, + ); + expect(result).toEqual({ ok: true, version: "1.2.3" }); + }); + + it("broken when the process exits non-zero", async () => { + const result = await probeVersionByExit(execPath, ["-e", "process.exit(1)"], TIMEOUT); + expect(result).toEqual({ ok: false, reason: "broken" }); + }); + + it("broken when the process exits 0 but prints no version", async () => { + const result = await probeVersionByExit( + execPath, + ["-e", "process.stdout.write('hello')"], + TIMEOUT, + ); + expect(result).toEqual({ ok: false, reason: "broken" }); + }); + + it("missing when the command does not exist (ENOENT)", async () => { + const result = await probeVersionByExit("definitely-not-a-real-binary-xyz", ["--version"], TIMEOUT); + expect(result).toEqual({ ok: false, reason: "missing" }); + }); + + // CASE A — the gate must NOT reject this: process exits fast but leaks stdout + // to a detached grandchild that holds the pipe open. Measuring "exit" → ok. + it("ok when the process exits fast but a child holds stdout open", async () => { + const held = [ + "process.stdout.write('2.0.0');", + "require('child_process').spawn(process.execPath, ['-e', 'setTimeout(function(){}, 3000)'], { stdio: ['ignore', 1, 'ignore'], detached: true }).unref();", + "process.exit(0);", + ].join(" "); + const result = await probeVersionByExit(execPath, ["-e", held], TIMEOUT); + expect(result).toEqual({ ok: true, version: "2.0.0" }); + }); + + // CASE B — the gate's reject path: genuinely slow to EXIT trips the timeout. + it("timeout when the process is slow to exit", async () => { + const result = await probeVersionByExit( + execPath, + ["-e", "setTimeout(function(){}, 5000)"], + 500, + ); + expect(result).toEqual({ ok: false, reason: "timeout" }); + }); +}); diff --git a/apps/server/tests/ru-fork/logTerminalStartFailure.test.ts b/apps/server/tests/ru-fork/logTerminalStartFailure.test.ts new file mode 100644 index 00000000000..b607e89caaf --- /dev/null +++ b/apps/server/tests/ru-fork/logTerminalStartFailure.test.ts @@ -0,0 +1,50 @@ +// ru-fork: proof that a failed terminal open is LOGGED to the server (the RPC +// observability seam is a no-op since telemetry was removed) AND that the +// failure is re-raised unchanged — so every caller keeps its error handling and +// the typed TerminalError still propagates. +import { describe, expect, it } from "vitest"; +import * as Effect from "effect/Effect"; +import * as Logger from "effect/Logger"; + +import { logTerminalStartFailure } from "../../src/ru-fork/terminal/logTerminalStartFailure.ts"; + +function captureLogs() { + const lines: string[] = []; + const logger = Logger.make(({ message }) => { + lines.push(Array.isArray(message) ? message.map((part) => String(part)).join(" ") : String(message)); + }); + return { lines, layer: Logger.layer([logger], { mergeWithExisting: false }) }; +} + +describe("logTerminalStartFailure", () => { + it("logs the real reason and re-raises the failure unchanged", async () => { + const { lines, layer } = captureLogs(); + const cause = new Error("Terminal cwd does not exist: C:/x"); + + const exit = await Effect.runPromiseExit( + Effect.fail(cause).pipe( + logTerminalStartFailure({ threadId: "thread-1", cwd: "C:/x" }), + Effect.provide(layer), + ), + ); + + // re-raised, not swallowed + expect(exit._tag).toBe("Failure"); + // logged at the server + expect(lines.some((line) => line.includes("terminal start failed"))).toBe(true); + }); + + it("does not log on success", async () => { + const { lines, layer } = captureLogs(); + + const value = await Effect.runPromise( + Effect.succeed(42).pipe( + logTerminalStartFailure({ threadId: "thread-1", cwd: "C:/x" }), + Effect.provide(layer), + ), + ); + + expect(value).toBe(42); + expect(lines).toHaveLength(0); + }); +}); diff --git a/apps/server/tests/ru-fork/terminalErrors.test.ts b/apps/server/tests/ru-fork/terminalErrors.test.ts new file mode 100644 index 00000000000..eba12bafac5 --- /dev/null +++ b/apps/server/tests/ru-fork/terminalErrors.test.ts @@ -0,0 +1,48 @@ +// ru-fork: the terminal-error formatter rebuilds the localized, user-facing +// message from a WIRE-DECODED error object (the tagged error's `message` getter +// is lost over the RPC boundary). This is the proof that a failed terminal open +// surfaces the REAL reason in the UI instead of a generic fallback. +import { describe, expect, it } from "vitest"; +import { terminalErrorMessage } from "@t3tools/contracts"; + +describe("terminalErrorMessage", () => { + it("formats a missing cwd (notFound)", () => { + expect( + terminalErrorMessage({ _tag: "TerminalCwdError", cwd: "C:/x", reason: "notFound" }), + ).toBe("Рабочий каталог терминала не существует: C:/x"); + }); + + it("formats a cwd that is not a directory", () => { + expect( + terminalErrorMessage({ _tag: "TerminalCwdError", cwd: "C:/x", reason: "notDirectory" }), + ).toBe("Рабочий каталог терминала не является папкой: C:/x"); + }); + + it("formats a stat failure, including the underlying cause message", () => { + expect( + terminalErrorMessage({ + _tag: "TerminalCwdError", + cwd: "C:/x", + reason: "statFailed", + cause: new Error("PermissionDenied"), + }), + ).toBe("Не удалось открыть рабочий каталог терминала: C:/x (PermissionDenied)"); + }); + + it("formats a stat failure without a usable cause message", () => { + expect( + terminalErrorMessage({ _tag: "TerminalCwdError", cwd: "C:/x", reason: "statFailed" }), + ).toBe("Не удалось открыть рабочий каталог терминала: C:/x"); + }); + + it("formats a history error", () => { + expect(terminalErrorMessage({ _tag: "TerminalHistoryError", operation: "read" })).toBe( + "Не удалось загрузить историю терминала (операция: read).", + ); + }); + + it("returns undefined for an unknown tag so callers fall back", () => { + expect(terminalErrorMessage({ _tag: "SomethingElse" })).toBeUndefined(); + expect(terminalErrorMessage({})).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 2294dafbb0f..70a976260ed 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -35,6 +35,7 @@ import { import { useShallow } from "zustand/react/shallow"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { readEnvironmentApi } from "../environmentApi"; +import { wsDebug } from "../ru-fork/debugging"; import { readPrimaryEnvironmentDescriptor, usePrimaryEnvironmentId } from "../environments/primary"; import { useSavedEnvironmentRegistryStore, @@ -727,9 +728,16 @@ function OpenCommandPaletteDialog() { const handleAddProject = useCallback( async (rawCwd: string) => { - if (!browseEnvironmentId) return; + wsDebug("addProject: click", { rawCwd, browseEnvironmentId }); + if (!browseEnvironmentId) { + wsDebug("addProject: abort no-env"); + return; + } const api = readEnvironmentApi(browseEnvironmentId); - if (!api) return; + if (!api) { + wsDebug("addProject: abort no-api"); + return; + } if (isUnsupportedWindowsProjectPath(rawCwd.trim(), browseEnvironmentPlatform)) { toastManager.add( @@ -754,7 +762,10 @@ function OpenCommandPaletteDialog() { } const cwd = resolveProjectPathForDispatch(rawCwd, currentProjectCwdForBrowse); - if (cwd.length === 0) return; + if (cwd.length === 0) { + wsDebug("addProject: abort empty-cwd"); + return; + } const existing = findProjectByPath( projects.filter((project) => project.environmentId === browseEnvironmentId), @@ -784,6 +795,7 @@ function OpenCommandPaletteDialog() { try { const projectId = newProjectId(); + wsDebug("addProject: dispatching project.create", { projectId, cwd }); await api.orchestration.dispatchCommand({ type: "project.create", commandId: newCommandId(), @@ -797,11 +809,15 @@ function OpenCommandPaletteDialog() { }, createdAt: new Date().toISOString(), }); + wsDebug("addProject: dispatch RESOLVED", { projectId }); await handleNewThread(scopeProjectRef(browseEnvironmentId, projectId), { envMode: settings.defaultThreadEnvMode, }).catch(() => undefined); setOpen(false); } catch (error) { + wsDebug("addProject: dispatch REJECTED", { + error: error instanceof Error ? error.message : String(error), + }); toastManager.add( stackedThreadToast({ type: "error", diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 166ad3b240f..c0da1b4b146 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -4,6 +4,7 @@ import { type ResolvedKeybindingsConfig, type ScopedThreadRef, type TerminalEvent, + terminalErrorMessage, type TerminalSessionSnapshot, type ThreadId, } from "@t3tools/contracts"; @@ -702,10 +703,14 @@ export function TerminalViewport({ } } catch (err) { if (disposed) return; - writeSystemMessage( - terminal, - err instanceof Error ? err.message : "Failed to open terminal", - ); + // ru-fork: rebuild the real reason from the wire-decoded terminal error + // (the tagged error's `message` getter is lost over the RPC boundary), + // falling back to a plain Error message, then a generic string. + const detail = + terminalErrorMessage(err as { _tag?: string }) ?? + (err instanceof Error ? err.message : undefined) ?? + "Не удалось открыть терминал."; + writeSystemMessage(terminal, detail); } }; diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index affca269bdf..b8539207d7f 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -97,6 +97,8 @@ const lastAppliedProjectionVersionByEnvironment = new Map< let activeService: EnvironmentServiceState | null = null; let needsProviderInvalidation = false; +let lastBrowserHiddenAt: number | null = null; +let lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY; // Thread detail subscription cache policy: // - Active consumers keep a subscription retained via refCount. @@ -108,6 +110,7 @@ let needsProviderInvalidation = false; const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000; const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32; const INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS = 150; +const BROWSER_RESUME_RECONNECT_COOLDOWN_MS = 2_000; const NOOP = () => undefined; function createDeferredPromise() { @@ -1074,6 +1077,58 @@ function stopActiveService() { activeService = null; } +function reconnectEnvironmentConnectionsAfterBrowserResume(reason: string): void { + const now = Date.now(); + if (now - lastBrowserResumeReconnectAt < BROWSER_RESUME_RECONNECT_COOLDOWN_MS) { + return; + } + + for (const connection of environmentConnections.values()) { + if (connection.client.isHeartbeatFresh()) { + continue; + } + lastBrowserResumeReconnectAt = now; + void connection.reconnect().catch((error) => { + console.warn("Environment reconnect after browser resume failed", { + environmentId: connection.environmentId, + reason, + error: error instanceof Error ? error.message : String(error), + }); + }); + } +} + +function subscribeBrowserResumeReconnects(): () => void { + if (typeof document === "undefined" || typeof window === "undefined") { + return NOOP; + } + + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") { + lastBrowserHiddenAt = Date.now(); + return; + } + if (document.visibilityState === "visible" && lastBrowserHiddenAt !== null) { + lastBrowserHiddenAt = null; + reconnectEnvironmentConnectionsAfterBrowserResume("visibilitychange"); + } + }; + + const handlePageShow = (event: PageTransitionEvent) => { + if (event.persisted || lastBrowserHiddenAt !== null) { + lastBrowserHiddenAt = null; + reconnectEnvironmentConnectionsAfterBrowserResume("pageshow"); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("pageshow", handlePageShow); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("pageshow", handlePageShow); + }; +} + export function subscribeEnvironmentConnections(listener: () => void): () => void { environmentConnectionListeners.add(listener); return () => { @@ -1252,12 +1307,15 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () .then(() => requestSavedEnvironmentSync()) .catch(() => undefined); + const unsubscribeBrowserResumeReconnects = subscribeBrowserResumeReconnects(); + activeService = { queryClient, queryInvalidationThrottler, refCount: 1, stop: () => { unsubscribeSavedEnvironments(); + unsubscribeBrowserResumeReconnects(); queryInvalidationThrottler.cancel(); }, }; @@ -1275,6 +1333,8 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () export async function resetEnvironmentServiceForTests(): Promise { stopActiveService(); + lastBrowserHiddenAt = null; + lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY; lastAppliedProjectionVersionByEnvironment.clear(); for (const key of Array.from(threadDetailSubscriptions.keys())) { disposeThreadDetailSubscriptionByKey(key); diff --git a/apps/web/src/rpc/protocol.ts b/apps/web/src/rpc/protocol.ts index 124b40d7d55..de416afb404 100644 --- a/apps/web/src/rpc/protocol.ts +++ b/apps/web/src/rpc/protocol.ts @@ -19,13 +19,31 @@ import { recordWsConnectionOpened, WS_RECONNECT_MAX_RETRIES, } from "./wsConnectionState"; +import { wsDebug } from "../ru-fork/debugging"; + +// ru-fork: connection-robustness restored from 8fc31793 (it was removed by +// c36945d8 "…+ cleanup"). The intentional-close flag lets the transport mark +// deliberate reconnect/dispose closes so they are NOT recorded as a disconnect +// (a deliberate close being misread re-triggered the retry and leaked a zombie +// socket). The telemetry/metadata that the cleanup also removed (connection +// label, version-mismatch hint, client tracing) is intentionally NOT restored. +export interface WsProtocolCloseContext { + readonly intentional: boolean; +} export interface WsProtocolLifecycleHandlers { readonly isActive?: () => boolean; + readonly isCloseIntentional?: () => boolean; readonly onAttempt?: (socketUrl: string) => void; readonly onOpen?: () => void; + readonly onHeartbeatPing?: () => void; + readonly onHeartbeatPong?: () => void; + readonly onHeartbeatTimeout?: () => void; readonly onError?: (message: string) => void; - readonly onClose?: (details: { readonly code: number; readonly reason: string }) => void; + readonly onClose?: ( + details: { readonly code: number; readonly reason: string }, + context: WsProtocolCloseContext, + ) => void; } export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); @@ -56,7 +74,11 @@ function resolveWsRpcSocketUrl(rawUrl: string): string { return resolved.toString(); } -function defaultLifecycleHandlers(): Required { +type ComposedWsProtocolLifecycleHandlers = Required< + Pick +>; + +function defaultLifecycleHandlers(): ComposedWsProtocolLifecycleHandlers { return { isActive: () => true, onAttempt: recordWsConnectionAttempt, @@ -65,8 +87,13 @@ function defaultLifecycleHandlers(): Required { clearAllTrackedRpcRequests(); recordWsConnectionErrored(message); }, - onClose: (details) => { + onClose: (details, context) => { clearAllTrackedRpcRequests(); + // ru-fork: a deliberate close (reconnect/dispose) is not a disconnect — + // don't record it (else it feeds the retry and leaves a zombie socket). + if (context.intentional) { + return; + } recordWsConnectionClosed(details); }, }; @@ -74,7 +101,7 @@ function defaultLifecycleHandlers(): Required { function composeLifecycleHandlers( handlers?: WsProtocolLifecycleHandlers, -): Required { +): ComposedWsProtocolLifecycleHandlers { const defaults = defaultLifecycleHandlers(); const isActive = handlers?.isActive ?? (() => true); @@ -101,12 +128,12 @@ function composeLifecycleHandlers( defaults.onError(message); handlers?.onError?.(message); }, - onClose: (details) => { + onClose: (details, context) => { if (!isActive()) { return; } - defaults.onClose(details); - handlers?.onClose?.(details); + defaults.onClose(details, context); + handlers?.onClose?.(details, context); }, }; } @@ -132,12 +159,14 @@ export function createWsRpcProtocolLayer( const trackingWebSocketConstructorLayer = Layer.succeed( Socket.WebSocketConstructor, (socketUrl, protocols) => { + wsDebug("ws attempt", { socketUrl }); lifecycle.onAttempt(socketUrl); const socket = new globalThis.WebSocket(socketUrl, protocols); socket.addEventListener( "open", () => { + wsDebug("ws open", { socketUrl }); lifecycle.onOpen(); }, { once: true }, @@ -145,6 +174,7 @@ export function createWsRpcProtocolLayer( socket.addEventListener( "error", () => { + wsDebug("ws error", { socketUrl }); lifecycle.onError("Unable to connect to the T3 server WebSocket."); }, { once: true }, @@ -152,10 +182,17 @@ export function createWsRpcProtocolLayer( socket.addEventListener( "close", (event) => { - lifecycle.onClose({ - code: event.code, - reason: event.reason, - }); + const intentional = handlers?.isCloseIntentional?.() ?? false; + wsDebug("ws close", { code: event.code, reason: event.reason, intentional }); + lifecycle.onClose( + { + code: event.code, + reason: event.reason, + }, + { + intentional, + }, + ); }, { once: true }, ); @@ -181,14 +218,17 @@ export function createWsRpcProtocolLayer( run: (clientId, writeResponse) => protocol.run(clientId, (response) => { if (response._tag === "Chunk" || response._tag === "Exit") { + wsDebug("rpc ←", { tag: response._tag, requestId: response.requestId }); acknowledgeRpcRequest(response.requestId); } else if (response._tag === "ClientProtocolError" || response._tag === "Defect") { + wsDebug("rpc ← error", { tag: response._tag }); clearAllTrackedRpcRequests(); } return writeResponse(response); }), send: (clientId, request, transferables) => { if (request._tag === "Request") { + wsDebug("rpc →", { id: request.id, tag: request.tag }); trackRpcRequestSent(request.id, request.tag); } return protocol.send(clientId, request, transferables); @@ -196,6 +236,39 @@ export function createWsRpcProtocolLayer( }), ), ); + // ru-fork: heartbeat hooks restored from 8fc31793. The RPC client pings the + // server every ~5s; on a missed pong it fires onPingTimeout. We surface + // ping/pong so the transport can track heartbeat freshness (used to skip + // needless reconnects) and clear in-flight requests on a real timeout. + const connectionHooksLayer = Layer.succeed( + RpcClient.ConnectionHooks, + RpcClient.ConnectionHooks.of({ + onConnect: Effect.void, + onDisconnect: Effect.void, + onPing: Effect.sync(() => { + wsDebug("hb ping", { active: lifecycle.isActive() }); + if (lifecycle.isActive()) { + handlers?.onHeartbeatPing?.(); + } + }), + onPong: Effect.sync(() => { + wsDebug("hb pong", { active: lifecycle.isActive() }); + if (lifecycle.isActive()) { + handlers?.onHeartbeatPong?.(); + } + }), + onPingTimeout: Effect.sync(() => { + wsDebug("hb TIMEOUT", { active: lifecycle.isActive() }); + if (lifecycle.isActive()) { + clearAllTrackedRpcRequests(); + recordWsConnectionErrored("WebSocket heartbeat timed out."); + handlers?.onHeartbeatTimeout?.(); + } + }), + }), + ); - return protocolLayer.pipe(Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson))); + return protocolLayer.pipe( + Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson, connectionHooksLayer)), + ); } diff --git a/apps/web/src/rpc/transportError.ts b/apps/web/src/rpc/transportError.ts index edc90a5a3c9..83f8aa86d4d 100644 --- a/apps/web/src/rpc/transportError.ts +++ b/apps/web/src/rpc/transportError.ts @@ -1,6 +1,7 @@ const TRANSPORT_ERROR_PATTERNS = [ /\bSocketCloseError\b/i, /\bSocketOpenError\b/i, + /\bSocket is not connected\b/i, /Unable to connect to the T3 server WebSocket\./i, /\bping timeout\b/i, ] as const; diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 13bf1f09ee7..409f9e3b16c 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -56,6 +56,7 @@ interface GitRunStackedActionOptions { export interface WsRpcClient { readonly dispose: () => Promise; readonly reconnect: () => Promise; + readonly isHeartbeatFresh: () => boolean; readonly terminal: { readonly open: RpcUnaryMethod; readonly write: RpcUnaryMethod; @@ -159,6 +160,7 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { resetWsReconnectBackoff(); await transport.reconnect(); }, + isHeartbeatFresh: () => transport.isHeartbeatFresh(), terminal: { open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)), write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)), diff --git a/apps/web/src/rpc/wsTransport.ts b/apps/web/src/rpc/wsTransport.ts index 582c8096b26..8e88582f907 100644 --- a/apps/web/src/rpc/wsTransport.ts +++ b/apps/web/src/rpc/wsTransport.ts @@ -17,6 +17,7 @@ import { type WsRpcProtocolSocketUrlProvider, } from "./protocol"; import { isTransportConnectionErrorMessage } from "./transportError"; +import { wsDebug } from "../ru-fork/debugging"; interface SubscribeOptions { readonly retryDelay?: Duration.Input; @@ -52,6 +53,11 @@ export class WsTransport { private reconnectChain: Promise = Promise.resolve(); private nextSessionId = 0; private activeSessionId = 0; + // ru-fork: restored from 8fc31793 (removed in c36945d8). intentionalCloseDepth + // marks a deliberate close (reconnect/dispose) so it isn't misread as a + // disconnect; lastHeartbeatPongAt powers isHeartbeatFresh. + private intentionalCloseDepth = 0; + private lastHeartbeatPongAt = 0; private session: TransportSession; constructor( @@ -131,6 +137,10 @@ export class WsTransport { } } + wsDebug("subscribe: stream start", { + tag: options?.tag, + sessionId: this.activeSessionId, + }); const runningStream = this.runStreamOnSession( session, connect, @@ -144,13 +154,20 @@ export class WsTransport { cancelCurrentStream = runningStream.cancel; await runningStream.completed; cancelCurrentStream = NOOP; + wsDebug("subscribe: stream completed", { tag: options?.tag }); } catch (error) { cancelCurrentStream = NOOP; if (!active || this.disposed) { return; } + wsDebug("subscribe: stream ended", { + tag: options?.tag, + error: formatErrorMessage(error), + }); + if (session !== this.session) { + wsDebug("subscribe: RE-ATTACH", { tag: options?.tag, to: this.activeSessionId }); continue; } @@ -168,6 +185,10 @@ export class WsTransport { }); } this.hasReportedTransportDisconnect = true; + wsDebug("subscribe: retry same session", { + tag: options?.tag, + sessionId: this.activeSessionId, + }); await sleep(retryDelayMs); } } @@ -190,8 +211,11 @@ export class WsTransport { } clearAllTrackedRpcRequests(); + this.lastHeartbeatPongAt = 0; const previousSession = this.session; + const previousSessionId = this.activeSessionId; this.session = this.createSession(); + wsDebug("transport.reconnect", { from: previousSessionId, to: this.activeSessionId }); await this.closeSession(previousSession); }); @@ -207,8 +231,17 @@ export class WsTransport { await this.closeSession(this.session); } + isHeartbeatFresh(maxAgeMs = 15_000): boolean { + return this.lastHeartbeatPongAt > 0 && Date.now() - this.lastHeartbeatPongAt <= maxAgeMs; + } + private closeSession(session: TransportSession) { + // ru-fork: mark this teardown intentional so the socket's close event is + // not recorded as a disconnect (which would re-trigger the retry / leak). + this.intentionalCloseDepth += 1; + wsDebug("closeSession", { intentionalCloseDepth: this.intentionalCloseDepth }); return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => { + this.intentionalCloseDepth = Math.max(0, this.intentionalCloseDepth - 1); session.runtime.dispose(); }); } @@ -217,10 +250,21 @@ export class WsTransport { const sessionId = this.nextSessionId + 1; this.nextSessionId = sessionId; this.activeSessionId = sessionId; + wsDebug("session created", { sessionId }); const runtime = ManagedRuntime.make( createWsRpcProtocolLayer(this.url, { ...this.lifecycleHandlers, isActive: () => !this.disposed && this.activeSessionId === sessionId, + // ru-fork: a close is intentional if we're disposing, mid-reconnect + // teardown, or the caller says so — restored from 8fc31793. + isCloseIntentional: () => + this.disposed || + this.intentionalCloseDepth > 0 || + this.lifecycleHandlers?.isCloseIntentional?.() === true, + onHeartbeatPong: () => { + this.lastHeartbeatPongAt = Date.now(); + this.lifecycleHandlers?.onHeartbeatPong?.(); + }, }), ); const clientScope = runtime.runSync(Scope.make()); diff --git a/apps/web/src/ru-fork/debugging/index.ts b/apps/web/src/ru-fork/debugging/index.ts new file mode 100644 index 00000000000..e31367abbed --- /dev/null +++ b/apps/web/src/ru-fork/debugging/index.ts @@ -0,0 +1,60 @@ +// ru-fork: lightweight, gated client-side debug logging shared across layers. +// +// Enable in the browser DevTools console with: +// localStorage.debugging = "ws" (then reload) +// The value is a comma/space-separated list of layers, so combine as needed: +// localStorage.debugging = "ws,mcp" +// Disable with: +// delete localStorage.debugging (then reload) +// +// When a layer is not listed, its logs are a no-op with zero console noise. The +// value is read once and cached, so toggling requires a reload — intentional, so +// hot paths (e.g. per-RPC `rpc →`/`rpc ←`) cost nothing in normal use. +// +// WS instrumentation context: ru-fork-instrumental/connection-stability/networking.md §7. + +export const DebuggingLayer = { + ws: "ws", + mcp: "mcp", +} as const; + +export type DebuggingLayer = (typeof DebuggingLayer)[keyof typeof DebuggingLayer]; + +let activeLayers: ReadonlySet | undefined; + +function getActiveLayers(): ReadonlySet { + if (activeLayers === undefined) { + try { + const raw = typeof localStorage !== "undefined" ? localStorage.getItem("debugging") : null; + activeLayers = new Set(raw ? raw.split(/[\s,]+/).filter(Boolean) : []); + } catch { + activeLayers = new Set(); + } + } + return activeLayers; +} + +export function isDebugEnabled(layer: DebuggingLayer): boolean { + return getActiveLayers().has(layer); +} + +export function debugLog( + layer: DebuggingLayer, + message: string, + data?: Record, +): void { + if (!isDebugEnabled(layer)) { + return; + } + const prefix = `[${layer}] ${message}`; + if (data === undefined) { + console.debug(prefix); + } else { + console.debug(prefix, data); + } +} + +// Convenience wrapper for the WS layer (used by the rpc transport instrumentation). +export function wsDebug(message: string, data?: Record): void { + debugLog(DebuggingLayer.ws, message, data); +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 05ef4a12142..21265c8b177 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -14,6 +14,7 @@ export * from "./server.ts"; // don't trip on these additions. export * from "./ru-fork/skills.ts"; export * from "./ru-fork/subagents.ts"; +export * from "./ru-fork/terminalErrors.ts"; export * from "./settings.ts"; export * from "./git.ts"; export * from "./vcs.ts"; diff --git a/packages/contracts/src/ru-fork/terminalErrors.ts b/packages/contracts/src/ru-fork/terminalErrors.ts new file mode 100644 index 00000000000..97c2cf24e9d --- /dev/null +++ b/packages/contracts/src/ru-fork/terminalErrors.ts @@ -0,0 +1,51 @@ +// ru-fork: localized (Russian) user-facing messages for terminal errors. +// +// Lives here — NOT inline in the upstream `terminal.ts` — so re-syncs from +// upstream t3code don't conflict, and so the WEB can rebuild the message from a +// wire-decoded error object: the class `message` getters in `terminal.ts` are +// lost over the RPC boundary (only the schema data fields survive), which is +// why a failed terminal open showed a generic "Failed to open terminal" +// instead of the real reason. +// +// Returns `undefined` for an unrecognized tag so callers can fall back to a +// generic message (or the raw `Error.message`). + +export interface TerminalErrorShape { + readonly _tag?: string; + readonly cwd?: string; + readonly reason?: string; + readonly operation?: string; + readonly cause?: unknown; +} + +export function terminalErrorMessage(error: TerminalErrorShape): string | undefined { + switch (error._tag) { + case "TerminalCwdError": { + const cwd = error.cwd ?? ""; + if (error.reason === "notDirectory") { + return `Рабочий каталог терминала не является папкой: ${cwd}`; + } + if (error.reason === "notFound") { + return `Рабочий каталог терминала не существует: ${cwd}`; + } + const causeMessage = + error.cause !== undefined && + error.cause !== null && + typeof error.cause === "object" && + "message" in error.cause + ? (error.cause as { readonly message?: unknown }).message + : undefined; + return typeof causeMessage === "string" && causeMessage.length > 0 + ? `Не удалось открыть рабочий каталог терминала: ${cwd} (${causeMessage})` + : `Не удалось открыть рабочий каталог терминала: ${cwd}`; + } + case "TerminalHistoryError": + return `Не удалось загрузить историю терминала (операция: ${error.operation ?? "?"}).`; + case "TerminalSessionLookupError": + return "Сессия терминала не найдена."; + case "TerminalNotRunningError": + return "Терминал не запущен."; + default: + return undefined; + } +} diff --git a/patches/effect@4.0.0-beta.59.patch b/patches/effect@4.0.0-beta.59.patch new file mode 100644 index 00000000000..479004acf33 --- /dev/null +++ b/patches/effect@4.0.0-beta.59.patch @@ -0,0 +1,277 @@ +diff --git a/dist/unstable/rpc/RpcClient.d.ts b/dist/unstable/rpc/RpcClient.d.ts +index 7c5970042df67a25b4145df017960b3e6febed9c..7af3374632d53ad59f24e80835aa6741853b1f2b 100644 +--- a/dist/unstable/rpc/RpcClient.d.ts ++++ b/dist/unstable/rpc/RpcClient.d.ts +@@ -2,6 +2,7 @@ import * as Cause from "../../Cause.ts"; + import * as Context from "../../Context.ts"; + import type * as Duration from "../../Duration.ts"; + import * as Effect from "../../Effect.ts"; ++import * as Exit from "../../Exit.ts"; + import * as Layer from "../../Layer.ts"; + import * as Queue from "../../Queue.ts"; + import * as Schedule from "../../Schedule.ts"; +@@ -192,9 +193,40 @@ export declare const layerProtocolWorker: (options: { + readonly targetUtilization?: number | undefined; + readonly timeToLive: Duration.Input; + }) => Layer.Layer; ++declare const RequestHooks_base: Context.ServiceClass Effect.Effect) | undefined; ++ readonly onRequestChunk?: ((info: { ++ readonly id: RequestId; ++ readonly tag: string; ++ readonly chunkCount: number; ++ }) => Effect.Effect) | undefined; ++ readonly onRequestExit?: ((info: { ++ readonly id: RequestId; ++ readonly tag: string; ++ readonly stream: boolean; ++ readonly exit: Exit.Exit; ++ }) => Effect.Effect) | undefined; ++ readonly onRequestInterrupt?: ((info: { ++ readonly id: RequestId; ++ readonly tag?: string | undefined; ++ }) => Effect.Effect) | undefined; ++}>; ++/** ++ * @since 4.0.0 ++ * @category RequestHooks ++ */ ++export declare class RequestHooks extends RequestHooks_base { ++} + declare const ConnectionHooks_base: Context.ServiceClass; + readonly onDisconnect: Effect.Effect; ++ readonly onPing?: Effect.Effect | undefined; ++ readonly onPong?: Effect.Effect | undefined; ++ readonly onPingTimeout?: Effect.Effect | undefined; + }>; + /** + * @since 4.0.0 +diff --git a/dist/unstable/rpc/RpcClient.js b/dist/unstable/rpc/RpcClient.js +index 951dfc2d667a9136cfa714dcd3195e6575917fde..599c0345873bedd72bc93afe80eab0c676319421 100644 +--- a/dist/unstable/rpc/RpcClient.js ++++ b/dist/unstable/rpc/RpcClient.js +@@ -42,6 +42,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro + const services = yield* Effect.context(); + const scope = Context.get(services, Scope.Scope); + const entries = new Map(); ++ const hooks = yield* Effect.serviceOption(RequestHooks); + let isShutdown = false; + yield* Scope.addFinalizer(scope, Effect.withFiber(parent => { + isShutdown = true; +@@ -79,6 +80,11 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro + return Effect.interrupt; + } + const id = generateRequestId(); ++ const onStart = Option.isSome(hooks) && hooks.value.onRequestStart ? hooks.value.onRequestStart({ ++ id, ++ tag: rpc._tag, ++ stream: false ++ }) : Effect.void; + const send = middleware(message => options.onFromClient({ + message, + context, +@@ -96,7 +102,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro + headers: Headers.merge(parentFiber.getRef(CurrentHeaders), headers) + }); + if (discard) { +- return send; ++ return onStart.pipe(Effect.andThen(send)); + } + let fiber; + return Effect.onInterrupt(Effect.callback(resume => { +@@ -114,7 +120,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro + } + }; + entries.set(id, entry); +- fiber = send.pipe(span ? Effect.withParentSpan(span, { ++ fiber = onStart.pipe(Effect.andThen(send), span ? Effect.withParentSpan(span, { + captureStackTrace: false + }) : identity, Effect.runForkWith(parentFiber.context)); + fiber.addObserver(exit => { +@@ -123,8 +129,9 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro + } + }); + }), interruptors => { ++ const entry = entries.get(id); + entries.delete(id); +- return Effect.andThen(Fiber.interrupt(fiber), sendInterrupt(id, Array.from(interruptors), context)); ++ return Effect.andThen(Fiber.interrupt(fiber), sendInterrupt(id, Array.from(interruptors), context, entry?.rpc._tag)); + }); + }); + const onStreamRequest = Effect.fnUntraced(function* (rpc, middleware, payload, headers, streamBufferSize, context) { +@@ -136,11 +143,17 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro + }); + const fiber = Fiber.getCurrent(); + const id = generateRequestId(); ++ const onStart = Option.isSome(hooks) && hooks.value.onRequestStart ? hooks.value.onRequestStart({ ++ id, ++ tag: rpc._tag, ++ stream: true ++ }) : Effect.void; + const scope = Context.getUnsafe(fiber.context, Scope.Scope); + yield* Scope.addFinalizerExit(scope, exit => { +- if (!entries.has(id)) return Effect.void; ++ const entry = entries.get(id); ++ if (!entry) return Effect.void; + entries.delete(id); +- return sendInterrupt(id, Exit.isFailure(exit) ? Array.from(Cause.interruptors(exit.cause)) : [], context); ++ return sendInterrupt(id, Exit.isFailure(exit) ? Array.from(Cause.interruptors(exit.cause)) : [], context, entry.rpc._tag); + }); + const queue = yield* Queue.bounded(streamBufferSize); + entries.set(id, { +@@ -148,9 +161,10 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro + rpc, + queue, + scope, +- context ++ context, ++ chunkCount: 0 + }); +- yield* middleware(message => options.onFromClient({ ++ yield* onStart.pipe(Effect.andThen(middleware(message => options.onFromClient({ + message, + context, + discard: false +@@ -165,7 +179,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro + sampled: span.sampled + } : {}), + headers: Headers.merge(fiber.getRef(CurrentHeaders), headers) +- }).pipe(span ? Effect.withParentSpan(span, { ++ }))).pipe(span ? Effect.withParentSpan(span, { + captureStackTrace: false + }) : identity, Effect.catchCause(error => Queue.failCause(queue, error)), Effect.interruptible, Effect.forkIn(scope, { + startImmediately: true +@@ -195,7 +209,10 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro + }); + }; + }; +- const sendInterrupt = (requestId, interruptors, context) => Effect.callback(resume => { ++ const sendInterrupt = (requestId, interruptors, context, tag) => (Option.isSome(hooks) && hooks.value.onRequestInterrupt ? hooks.value.onRequestInterrupt({ ++ id: requestId, ++ tag ++ }) : Effect.void).pipe(Effect.andThen(Effect.callback(resume => { + const parentFiber = Fiber.getCurrent(); + const fiber = options.onFromClient({ + message: { +@@ -209,7 +226,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro + fiber.addObserver(() => { + resume(Effect.void); + }); +- }); ++ }))); + const write = message => { + switch (message._tag) { + case "Chunk": +@@ -217,7 +234,13 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro + const requestId = message.requestId; + const entry = entries.get(requestId); + if (!entry || entry._tag !== "Queue") return Effect.void; +- return Queue.offerAll(entry.queue, message.values).pipe(supportsAck ? Effect.flatMap(() => options.onFromClient({ ++ const chunkCount = entry.chunkCount += message.values.length; ++ const onChunk = Option.isSome(hooks) && hooks.value.onRequestChunk ? hooks.value.onRequestChunk({ ++ id: requestId, ++ tag: entry.rpc._tag, ++ chunkCount ++ }) : Effect.void; ++ return Queue.offerAll(entry.queue, message.values).pipe(Effect.andThen(onChunk), supportsAck ? Effect.flatMap(() => options.onFromClient({ + message: { + _tag: "Ack", + requestId: message.requestId +@@ -232,11 +255,18 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro + const entry = entries.get(requestId); + if (!entry) return Effect.void; + entries.delete(requestId); ++ const onExit = Option.isSome(hooks) && hooks.value.onRequestExit ? hooks.value.onRequestExit({ ++ id: requestId, ++ tag: entry.rpc._tag, ++ stream: entry._tag === "Queue", ++ exit: message.exit ++ }) : Effect.void; + if (entry._tag === "Effect") { +- entry.resume(message.exit); +- return Effect.void; ++ return onExit.pipe(Effect.andThen(Effect.sync(() => { ++ entry.resume(message.exit); ++ }))); + } +- return message.exit._tag === "Success" ? Queue.end(entry.queue) : Queue.failCause(entry.queue, message.exit.cause); ++ return onExit.pipe(Effect.andThen(message.exit._tag === "Success" ? Queue.end(entry.queue) : Queue.failCause(entry.queue, message.exit.cause))); + } + case "Defect": + { +@@ -530,7 +560,7 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun + const requestClientMap = new Map(); + const write = yield* socket.writer; + let parser = serialization.makeUnsafe(); +- const pinger = yield* makePinger(write(parser.encode(constPing))); ++ const pinger = yield* makePinger(write(parser.encode(constPing)), Option.isSome(hooks) ? hooks.value : undefined); + let currentError; + const onOpen = Effect.suspend(() => { + currentError = undefined; +@@ -550,8 +580,7 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun + body: () => { + const response = responses[i++]; + if (response._tag === "Pong") { +- pinger.onPong(); +- return Effect.void; ++ return pinger.onPong; + } + if ("requestId" in response) { + const clientId = requestClientMap.get(response.requestId); +@@ -579,12 +608,12 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun + } + }, { + onOpen +- }).pipe(Effect.raceFirst(Effect.flatMap(pinger.timeout, () => Effect.fail(new Socket.SocketError({ ++ }).pipe(Effect.raceFirst(Effect.flatMap(pinger.timeout, () => (Option.isSome(hooks) && hooks.value.onPingTimeout ? hooks.value.onPingTimeout : Effect.void).pipe(Effect.andThen(Effect.fail(new Socket.SocketError({ + reason: new Socket.SocketOpenError({ + kind: "Timeout", + cause: new Error("ping timeout") + }) +- }))))); ++ }))))))); + }).pipe(Effect.flatMap(() => Effect.fail(new Socket.SocketError({ + reason: new Socket.SocketCloseError({ + code: 1000 +@@ -626,20 +655,20 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun + }; + })); + const defaultRetryPolicy = /*#__PURE__*/Schedule.exponential(500, 1.5).pipe(/*#__PURE__*/Schedule.either(/*#__PURE__*/Schedule.spaced(5000))); +-const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing) { ++const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing, hooks) { + let recievedPong = true; + const latch = Latch.makeUnsafe(); + const reset = () => { + recievedPong = true; + latch.closeUnsafe(); + }; +- const onPong = () => { ++ const onPong = Effect.sync(() => { + recievedPong = true; +- }; ++ }).pipe(Effect.andThen(hooks?.onPong ?? Effect.void)); + yield* Effect.suspend(() => { + if (!recievedPong) return latch.open; + recievedPong = false; +- return writePing; ++ return (hooks?.onPing ?? Effect.void).pipe(Effect.andThen(writePing)); + }).pipe(Effect.delay("5 seconds"), Effect.ignore, Effect.forever, Effect.interruptible, Effect.forkScoped); + return { + timeout: latch.await, +@@ -773,6 +802,11 @@ export const makeProtocolWorker = options => Protocol.make(Effect.fnUntraced(fun + * @category protocol + */ + export const layerProtocolWorker = /*#__PURE__*/flow(makeProtocolWorker, /*#__PURE__*/Layer.effect(Protocol)); ++/** ++ * @since 4.0.0 ++ * @category RequestHooks ++ */ ++export class RequestHooks extends /*#__PURE__*/Context.Service()("effect/rpc/RpcClient/RequestHooks") {} + /** + * @since 4.0.0 + * @category ConnectionHooks diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f655cf25dfb..66b245f9c7b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,3 +21,10 @@ catalog: typescript: 5.9.3 vitest: 3.2.4 "@vitest/coverage-v8": 3.2.4 + +# ru-fork: restored (removed in c36945d8). Patches effect's RpcClient to expose +# ConnectionHooks ping/pong/timeout hooks used by the WS transport heartbeat. +# Functionally identical to t3code's patches/effect@4.0.0-beta.78.patch, pinned +# to our beta.59. Recovered from 8fc31793. +patchedDependencies: + effect@4.0.0-beta.59: patches/effect@4.0.0-beta.59.patch