Files
openchamber/packages/web/bin/lib/cli-control.js
T
Bohdan Triapitsyn b6c58df949 fix(cli): give worktree provisioning a timeout that fits the work
Creating a session with a worktree reported "Request to /api/openchamber/control
timed out after 4000ms" while the worktree was in fact created, leaving the user
with a failure message, a real worktree, and no session id. Reported alongside
worktree creation appearing to take forever.

The client HTTP timeout was extended only when the caller asked to wait for the
session. Provisioning a worktree is slow on its own: it runs git against the
repository and prepares a new directory. Measured on a cold path immediately
after a restart it takes about four seconds, which lands exactly on the four
second default and explains why this failed intermittently rather than always.
A warm run finishes in well under two.

The timeout now follows the work being requested rather than only the wait
flag, and covers whichever of the two windows is longer. The server always
completed the operation, so nothing about the outcome changes: only the client
stops abandoning it.

Verified by creating a worktree on the cold path immediately after a restart,
which previously failed here: 4004 ms and 1376 ms, both reported ok.
2026-08-04 01:33:56 +03:00

56 lines
2.8 KiB
JavaScript

import { TunnelCliError, EXIT_CODE } from './cli-errors.js';
import { requestJson } from './cli-http.js';
const asNonEmptyString = (value) => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const DEFAULT_WAIT_TIMEOUT_SECONDS = 600;
const WAIT_HTTP_TIMEOUT_BUFFER_MS = 30_000;
// Provisioning a worktree is not one of the instant control calls the short
// default is sized for: it runs git against the repository and prepares a new
// directory, which on a cold path takes longer than the default allows. The
// server finishes the work regardless of the client giving up, so a client-side
// timeout here reported a failure for a worktree that was in fact created.
const WORKTREE_PROVISION_TIMEOUT_MS = 120_000;
// The control service blocks server-side while wait is set, so the client
// HTTP timeout must outlive the requested wait window instead of the short
// default used for instant control calls.
export const resolveControlTimeoutMs = (input, options) => {
if (Number.isFinite(options?.timeoutMs) && options.timeoutMs > 0) return options.timeoutMs;
const provisionsWorktree = asNonEmptyString(input?.worktree) !== null;
if (input?.wait !== true) {
return provisionsWorktree ? WORKTREE_PROVISION_TIMEOUT_MS : undefined;
}
const waitSeconds = Number(input?.timeout) > 0 ? Number(input.timeout) : DEFAULT_WAIT_TIMEOUT_SECONDS;
const waitTimeoutMs = (waitSeconds * 1000) + WAIT_HTTP_TIMEOUT_BUFFER_MS;
// Waiting for the session and provisioning its worktree are additive, so the
// window must cover whichever is longer rather than only the wait.
return provisionsWorktree ? Math.max(waitTimeoutMs, WORKTREE_PROVISION_TIMEOUT_MS) : waitTimeoutMs;
};
export const requestControlAction = async (port, action, input, options = {}) => {
const timeoutMs = resolveControlTimeoutMs(input, options);
const { response, body } = await requestJson(port, '/api/openchamber/control', {
...options,
...(timeoutMs ? { timeoutMs } : {}),
method: 'POST',
body: JSON.stringify({ action, input }),
});
if (response?.ok) return body;
const isPartial = body?.partial === true;
const partialSessionId = isPartial ? asNonEmptyString(body?.sessionId) : null;
const partialDirectory = isPartial ? asNonEmptyString(body?.directory) : null;
const partialSubject = body?.partialAction === 'goal-configured' ? 'Goal on session' : 'Forked session';
const partial = partialSessionId
? ` ${partialSubject} ${partialSessionId} remains available${partialDirectory ? ` in ${partialDirectory}` : ''}.`
: '';
const message = `${asNonEmptyString(body?.error) || `Failed to execute ${action}`}${partial}`;
const status = Number(response?.status);
throw new TunnelCliError(message, status === 400 || status === 404 ? EXIT_CODE.USAGE_ERROR : EXIT_CODE.GENERAL_ERROR);
};