perf improvements + /login fix
This commit is contained in:
@@ -544,9 +544,21 @@ export async function runHeadless(
|
||||
proactiveModule.activateProactive('command')
|
||||
}
|
||||
|
||||
// Periodically force a full GC to keep memory usage in check
|
||||
// Headless sessions can run for a long time, but forcing a full GC every
|
||||
// second burns CPU even when memory is healthy. Poll less often and only
|
||||
// collect aggressively once the heap is actually elevated, unless the user
|
||||
// explicitly opts into the old behavior for profiling/debugging.
|
||||
if (typeof Bun !== 'undefined') {
|
||||
const gcTimer = setInterval(Bun.gc, 1000)
|
||||
const GC_INTERVAL_MS = 15_000
|
||||
const GC_HEAP_THRESHOLD_BYTES = 768 * 1024 * 1024
|
||||
const gcTimer = setInterval(() => {
|
||||
if (
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_FORCE_PERIODIC_GC) ||
|
||||
process.memoryUsage().heapUsed >= GC_HEAP_THRESHOLD_BYTES
|
||||
) {
|
||||
Bun.gc(true)
|
||||
}
|
||||
}, GC_INTERVAL_MS)
|
||||
gcTimer.unref()
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -377,7 +377,13 @@ const MessagesImpl = ({
|
||||
columns
|
||||
} = useTerminalSize();
|
||||
const toggleShowAllShortcut = useShortcutDisplay('transcript:toggleShowAll', 'Transcript', 'Ctrl+E');
|
||||
const normalizedMessages = useMemo(() => normalizeMessages(messages).filter(isNotEmptyMessage), [messages]);
|
||||
// In the main-screen renderer, pre-compact history already lives in native
|
||||
// terminal scrollback, so normalizing it again just adds O(n) work on every
|
||||
// render. Fullscreen/transcript modes still need the full in-memory history.
|
||||
const normalizationSourceMessages = useMemo(() => verbose || isFullscreenEnvEnabled() ? messages : getMessagesAfterCompactBoundary(messages, {
|
||||
includeSnipped: true
|
||||
}), [messages, verbose]);
|
||||
const normalizedMessages = useMemo(() => normalizeMessages(normalizationSourceMessages).filter(isNotEmptyMessage), [normalizationSourceMessages]);
|
||||
|
||||
// Check if streaming thinking should be visible (streaming or within 30s timeout)
|
||||
const isStreamingThinkingVisible = useMemo(() => {
|
||||
@@ -485,18 +491,7 @@ const MessagesImpl = ({
|
||||
hasTruncatedMessages: hasTruncatedMessages_0,
|
||||
hiddenMessageCount: hiddenMessageCount_0
|
||||
} = useMemo(() => {
|
||||
// In fullscreen mode the alt buffer has no native scrollback, so the
|
||||
// compact-boundary filter just hides history the ScrollBox could
|
||||
// otherwise scroll to. Main-screen mode keeps the filter — pre-compact
|
||||
// rows live above the viewport in native scrollback there, and
|
||||
// re-rendering them triggers full resets.
|
||||
// includeSnipped: UI rendering keeps snipped messages for scrollback
|
||||
// (this PR's core goal — full history in UI, filter only for the model).
|
||||
// Also avoids a UUID mismatch: normalizeMessages derives new UUIDs, so
|
||||
// projectSnippedView's check against original removedUuids would fail.
|
||||
const compactAwareMessages = verbose || isFullscreenEnvEnabled() ? normalizedMessages : getMessagesAfterCompactBoundary(normalizedMessages, {
|
||||
includeSnipped: true
|
||||
});
|
||||
const compactAwareMessages = normalizedMessages;
|
||||
const messagesToShowNotTruncated = reorderMessagesInUI(compactAwareMessages.filter((msg_2): msg_2 is Exclude<NormalizedMessage, ProgressMessageType> => msg_2.type !== 'progress')
|
||||
// CC-724: drop attachment messages that AttachmentMessage renders as
|
||||
// null (hook_success, hook_additional_context, hook_cancelled, etc.)
|
||||
|
||||
88
src/components/OpenRouterLoginFlow.tsx
Normal file
88
src/components/OpenRouterLoginFlow.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Box, Text } from '../ink.js'
|
||||
import { saveOpenRouterApiKey } from '../utils/auth.js'
|
||||
import { Spinner } from './Spinner.js'
|
||||
import TextInput from './TextInput.js'
|
||||
|
||||
type OpenRouterLoginFlowProps = {
|
||||
onDone: () => void
|
||||
startingMessage?: string
|
||||
}
|
||||
|
||||
export function OpenRouterLoginFlow({
|
||||
onDone,
|
||||
startingMessage,
|
||||
}: OpenRouterLoginFlowProps): React.ReactNode {
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [cursorOffset, setCursorOffset] = useState(0)
|
||||
|
||||
async function handleSubmit(value: string): Promise<void> {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsBusy(true)
|
||||
setStatus(null)
|
||||
try {
|
||||
await saveOpenRouterApiKey(trimmed)
|
||||
onDone()
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isBusy) {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Spinner />
|
||||
<Text>Configuring OpenRouter login for Better-Clawd...</Text>
|
||||
</Box>
|
||||
<Text dimColor={true}>
|
||||
OpenRouter support uses your OpenRouter API key with the Responses API
|
||||
endpoint.
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
{startingMessage ??
|
||||
'Better-Clawd can use OpenRouter with your OpenRouter API key.'}
|
||||
</Text>
|
||||
<Text dimColor={true}>
|
||||
Paste your OpenRouter key to use `https://openrouter.ai/api/v1` and the
|
||||
Responses API compatibility layer.
|
||||
</Text>
|
||||
<Box>
|
||||
<Text>Paste your OpenRouter API key:</Text>
|
||||
<TextInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={handleSubmit}
|
||||
onExit={() => {
|
||||
setInputValue('')
|
||||
setCursorOffset(0)
|
||||
}}
|
||||
cursorOffset={cursorOffset}
|
||||
onChangeCursorOffset={setCursorOffset}
|
||||
columns={72}
|
||||
mask="*"
|
||||
/>
|
||||
</Box>
|
||||
{status ? <Text color="error">{status}</Text> : null}
|
||||
<Text dimColor={true}>
|
||||
Press <Text bold={true}>Enter</Text> to save, or <Text bold={true}>Esc</Text>{' '}
|
||||
to cancel.
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
74
src/main.tsx
74
src/main.tsx
@@ -37,7 +37,6 @@ import { hasGrowthBookEnvOverride, initializeGrowthBook, refreshGrowthBookAfterA
|
||||
import { fetchBootstrapData } from './services/api/bootstrap.js';
|
||||
import { type DownloadResult, downloadSessionFiles, type FilesApiConfig, parseFileSpecs } from './services/api/filesApi.js';
|
||||
import { prefetchPassesEligibility } from './services/api/referral.js';
|
||||
import { prefetchOfficialMcpUrls } from './services/mcp/officialRegistry.js';
|
||||
import type { McpSdkServerConfig, McpServerConfig, ScopedMcpServerConfig } from './services/mcp/types.js';
|
||||
import { isPolicyAllowed, loadPolicyLimits, refreshPolicyLimits, waitForPolicyLimitsToLoad } from './services/policyLimits/index.js';
|
||||
import { loadRemoteManagedSettings, refreshRemoteManagedSettings } from './services/remoteManagedSettings/index.js';
|
||||
@@ -58,8 +57,6 @@ import { createSystemMessage, createUserMessage } from './utils/messages.js';
|
||||
import { getPlatform } from './utils/platform.js';
|
||||
import { getBaseRenderOptions } from './utils/renderOptions.js';
|
||||
import { getSessionIngressAuthToken } from './utils/sessionIngressAuth.js';
|
||||
import { settingsChangeDetector } from './utils/settings/changeDetector.js';
|
||||
import { skillChangeDetector } from './utils/skills/skillChangeDetector.js';
|
||||
import { jsonParse, writeFileSync_DEPRECATED } from './utils/slowOperations.js';
|
||||
import { computeInitialTeamContext } from './utils/swarm/reconnection.js';
|
||||
import { initializeWarningHandler } from './utils/warningHandler.js';
|
||||
@@ -90,12 +87,10 @@ import type { StatsStore } from './context/stats.js';
|
||||
import { launchAssistantInstallWizard, launchAssistantSessionChooser, launchInvalidSettingsDialog, launchResumeChooser, launchSnapshotUpdateDialog, launchTeleportRepoMismatchDialog, launchTeleportResumeWrapper } from './dialogLaunchers.js';
|
||||
import { SHOW_CURSOR } from './ink/termio/dec.js';
|
||||
import { exitWithError, exitWithMessage, getRenderContext, renderAndRun, showSetupScreens } from './interactiveHelpers.js';
|
||||
import { initBuiltinPlugins } from './plugins/bundled/index.js';
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
import { checkQuotaStatus } from './services/claudeAiLimits.js';
|
||||
import { getMcpToolsCommandsAndResources, prefetchAllMcpResources } from './services/mcp/client.js';
|
||||
import { VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES } from './services/plugins/pluginCliCommands.js';
|
||||
import { initBundledSkills } from './skills/bundled/index.js';
|
||||
import type { AgentColorName } from './tools/AgentTool/agentColorManager.js';
|
||||
import { getActiveAgentsFromList, getAgentDefinitionsWithOverrides, isBuiltInAgent, isCustomAgent, parseAgentsFromJson } from './tools/AgentTool/loadAgentsDir.js';
|
||||
import type { LogOption } from './types/logs.js';
|
||||
@@ -119,8 +114,6 @@ import { getDefaultMainLoopModel, getUserSpecifiedModelSetting, normalizeModelSt
|
||||
import { ensureModelStringsInitialized } from './utils/model/modelStrings.js';
|
||||
import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js';
|
||||
import { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, isDefaultPermissionModeAuto, parseToolListFromCLI, removeDangerousPermissions, stripDangerousPermissionsForAutoMode, verifyAutoModeGateAccess } from './utils/permissions/permissionSetup.js';
|
||||
import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js';
|
||||
import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js';
|
||||
import { getManagedPluginNames } from './utils/plugins/managedPlugins.js';
|
||||
import { getGlobExclusionsForPluginCache } from './utils/plugins/orphanedPluginFilter.js';
|
||||
import { getPluginSeedDirs } from './utils/plugins/pluginDirectories.js';
|
||||
@@ -146,7 +139,6 @@ import { clearServerCache } from 'src/services/mcp/client.js';
|
||||
import { areMcpConfigsAllowedWithEnterpriseMcpConfig, dedupClaudeAiMcpServers, doesEnterpriseMcpConfigExist, filterMcpServersByPolicy, getClaudeCodeMcpConfigs, getMcpServerSignature, parseMcpConfig, parseMcpConfigFromFilePath } from 'src/services/mcp/config.js';
|
||||
import { excludeCommandsByServer, excludeResourcesByServer } from 'src/services/mcp/utils.js';
|
||||
import { isXaaEnabled } from 'src/services/mcp/xaaIdpLogin.js';
|
||||
import { getRelevantTips } from 'src/services/tips/tipRegistry.js';
|
||||
import { logContextMetrics } from 'src/utils/api.js';
|
||||
import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, isClaudeInChromeMCPServer } from 'src/utils/claudeInChrome/common.js';
|
||||
import { registerCleanup } from 'src/utils/cleanupRegistry.js';
|
||||
@@ -159,7 +151,6 @@ import { errorMessage, getErrnoCode, isENOENT, TeleportOperationError, toError }
|
||||
import { getFsImplementation, safeResolvePath } from 'src/utils/fsOperations.js';
|
||||
import { gracefulShutdown, gracefulShutdownSync } from 'src/utils/gracefulShutdown.js';
|
||||
import { setAllHookEventsEnabled } from 'src/utils/hooks/hookEvents.js';
|
||||
import { refreshModelCapabilities } from 'src/utils/model/modelCapabilities.js';
|
||||
import { peekForStdinData, writeToStderr } from 'src/utils/process.js';
|
||||
import { setCwd } from 'src/utils/Shell.js';
|
||||
import { type ProcessedResume, processResumedConversation } from 'src/utils/sessionRestore.js';
|
||||
@@ -379,6 +370,32 @@ function prefetchSystemContextIfSafe(): void {
|
||||
// Otherwise, don't prefetch - wait for trust to be established first
|
||||
}
|
||||
|
||||
function runDeferredStartupTask(
|
||||
label: string,
|
||||
task: () => Promise<void>,
|
||||
): void {
|
||||
void task().catch(error => {
|
||||
logForDebugging(
|
||||
`[STARTUP] deferred ${label} failed: ${errorMessage(error)}`,
|
||||
{ level: 'warn' },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function runPluginRuntimeBookkeeping(): Promise<void> {
|
||||
const [
|
||||
{ initializeVersionedPlugins },
|
||||
{ cleanupOrphanedPluginVersionsInBackground },
|
||||
] = await Promise.all([
|
||||
import('./utils/plugins/installedPluginsManager.js'),
|
||||
import('./utils/plugins/cacheUtils.js'),
|
||||
]);
|
||||
await initializeVersionedPlugins();
|
||||
profileCheckpoint('action_after_plugins_init');
|
||||
await cleanupOrphanedPluginVersionsInBackground();
|
||||
void getGlobExclusionsForPluginCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start background prefetches and housekeeping that are NOT needed before first render.
|
||||
* These are deferred from setup() to reduce event loop contention and child process
|
||||
@@ -404,7 +421,10 @@ export function startDeferredPrefetches(): void {
|
||||
void initUser();
|
||||
void getUserContext();
|
||||
prefetchSystemContextIfSafe();
|
||||
void getRelevantTips();
|
||||
runDeferredStartupTask('tips prefetch', async () => {
|
||||
const { getRelevantTips } = await import('./services/tips/tipRegistry.js');
|
||||
await getRelevantTips();
|
||||
});
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) && !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH)) {
|
||||
void prefetchAwsCredentialsAndBedRockInfoIfSafe();
|
||||
}
|
||||
@@ -415,13 +435,25 @@ export function startDeferredPrefetches(): void {
|
||||
|
||||
// Analytics and feature flag initialization
|
||||
void initializeAnalyticsGates();
|
||||
void prefetchOfficialMcpUrls();
|
||||
void refreshModelCapabilities();
|
||||
runDeferredStartupTask('official MCP registry prefetch', async () => {
|
||||
const { prefetchOfficialMcpUrls } = await import('./services/mcp/officialRegistry.js');
|
||||
await prefetchOfficialMcpUrls();
|
||||
});
|
||||
runDeferredStartupTask('model capabilities refresh', async () => {
|
||||
const { refreshModelCapabilities } = await import('./utils/model/modelCapabilities.js');
|
||||
await refreshModelCapabilities();
|
||||
});
|
||||
|
||||
// File change detectors deferred from init() to unblock first render
|
||||
void settingsChangeDetector.initialize();
|
||||
runDeferredStartupTask('settings change detector', async () => {
|
||||
const { settingsChangeDetector } = await import('./utils/settings/changeDetector.js');
|
||||
await settingsChangeDetector.initialize();
|
||||
});
|
||||
if (!isBareMode()) {
|
||||
void skillChangeDetector.initialize();
|
||||
runDeferredStartupTask('skill change detector', async () => {
|
||||
const { skillChangeDetector } = await import('./utils/skills/skillChangeDetector.js');
|
||||
await skillChangeDetector.initialize();
|
||||
});
|
||||
}
|
||||
|
||||
// Event loop stall detector — logs when the main thread is blocked >500ms
|
||||
@@ -1921,6 +1953,10 @@ async function run(): Promise<CommanderCommand> {
|
||||
// reads synchronously. Previously ran inside setup() after ~20ms of
|
||||
// await points, so the parallel getCommands() memoized an empty list.
|
||||
if (process.env.CLAUDE_CODE_ENTRYPOINT !== 'local-agent') {
|
||||
const [{ initBuiltinPlugins }, { initBundledSkills }] = await Promise.all([
|
||||
import('./plugins/bundled/index.js'),
|
||||
import('./skills/bundled/index.js'),
|
||||
]);
|
||||
initBuiltinPlugins();
|
||||
initBundledSkills();
|
||||
}
|
||||
@@ -2556,17 +2592,11 @@ async function run(): Promise<CommanderCommand> {
|
||||
// skip — no-op
|
||||
} else if (isNonInteractiveSession) {
|
||||
// In headless mode, await to ensure plugin sync completes before CLI exits
|
||||
await initializeVersionedPlugins();
|
||||
profileCheckpoint('action_after_plugins_init');
|
||||
void cleanupOrphanedPluginVersionsInBackground().then(() => getGlobExclusionsForPluginCache());
|
||||
await runPluginRuntimeBookkeeping();
|
||||
} else {
|
||||
// In interactive mode, fire-and-forget — this is purely bookkeeping
|
||||
// that doesn't affect runtime behavior of the current session
|
||||
void initializeVersionedPlugins().then(async () => {
|
||||
profileCheckpoint('action_after_plugins_init');
|
||||
await cleanupOrphanedPluginVersionsInBackground();
|
||||
void getGlobExclusionsForPluginCache();
|
||||
});
|
||||
void runPluginRuntimeBookkeeping().catch(error => logError(toError(error)));
|
||||
}
|
||||
const setupTrigger = initOnly || init ? 'init' : maintenance ? 'maintenance' : null;
|
||||
if (initOnly) {
|
||||
|
||||
@@ -150,6 +150,7 @@ import { useMergedTools } from '../hooks/useMergedTools.js';
|
||||
import { mergeAndFilterTools } from '../utils/toolPool.js';
|
||||
import { useMergedCommands } from '../hooks/useMergedCommands.js';
|
||||
import { useSkillsChange } from '../hooks/useSkillsChange.js';
|
||||
import { useSettingsChange } from '../hooks/useSettingsChange.js';
|
||||
import { useManagePlugins } from '../hooks/useManagePlugins.js';
|
||||
import { Messages } from '../components/Messages.js';
|
||||
import { TaskListV2 } from '../components/TaskListV2.js';
|
||||
@@ -295,6 +296,32 @@ const EMPTY_MCP_CLIENTS: MCPServerConnection[] = [];
|
||||
const EMPTY_TOOL_USE_CONFIRM_QUEUE: ToolUseConfirm[] = [];
|
||||
const EMPTY_IN_PROGRESS_TOOL_USE_IDS = new Set<string>();
|
||||
|
||||
type TurnContextSnapshot = {
|
||||
key: string;
|
||||
defaultSystemPrompt: string[];
|
||||
userContext: {
|
||||
[k: string]: string;
|
||||
};
|
||||
systemContext: {
|
||||
[k: string]: string;
|
||||
};
|
||||
};
|
||||
|
||||
function getMcpContextSignature(mcpClients: MCPServerConnection[]): string {
|
||||
return mcpClients.map(client => {
|
||||
if (client.type === 'connected') {
|
||||
return [client.name, client.config.type ?? 'stdio', client.instructions ?? ''].join('::');
|
||||
}
|
||||
return [client.name, client.type].join('::');
|
||||
}).join('||');
|
||||
}
|
||||
|
||||
function buildTurnContextCacheKey(model: string, tools: ReadonlyArray<{
|
||||
name: string;
|
||||
}>, additionalWorkingDirectories: readonly string[], mcpClients: MCPServerConnection[]): string {
|
||||
return [model, tools.map(tool => tool.name).sort().join('|'), additionalWorkingDirectories.join('|'), getMcpContextSignature(mcpClients)].join('|||');
|
||||
}
|
||||
|
||||
// Stable stub for useAssistantHistory's non-KAIROS branch — avoids a new
|
||||
// function identity each render, which would break composedOnScroll's memo.
|
||||
const HISTORY_STUB = {
|
||||
@@ -681,9 +708,16 @@ export function REPL({
|
||||
|
||||
// Local state for commands (hot-reloadable when skill files change)
|
||||
const [localCommands, setLocalCommands] = useState(initialCommands);
|
||||
const turnContextCacheRef = useRef<TurnContextSnapshot | null>(null);
|
||||
|
||||
// Watch for skill file changes and reload all commands
|
||||
useSkillsChange(isRemoteSession ? undefined : getProjectRoot(), setLocalCommands);
|
||||
useEffect(() => {
|
||||
turnContextCacheRef.current = null;
|
||||
}, [localCommands]);
|
||||
useSettingsChange(() => {
|
||||
turnContextCacheRef.current = null;
|
||||
});
|
||||
|
||||
// Track proactive mode for tools dependency - SleepTool filters by proactive state
|
||||
const proactiveActive = React.useSyncExternalStore(proactiveModule?.subscribeToProactiveChanges ?? PROACTIVE_NO_OP_SUBSCRIBE, proactiveModule?.isProactiveActive ?? PROACTIVE_FALSE);
|
||||
@@ -2531,6 +2565,23 @@ export function REPL({
|
||||
contentReplacementState: contentReplacementStateRef.current
|
||||
};
|
||||
}, [commands, combinedInitialTools, mainThreadAgentDefinition, debug, initialMcpClients, ideInstallationStatus, dynamicMcpConfig, theme, allowedAgentTypes, store, setAppState, reverify, addNotification, setMessages, onChangeDynamicMcpConfig, resume, requestPrompt, disabled, customSystemPrompt, appendSystemPrompt, setConversationId]);
|
||||
const loadTurnContext = useCallback(async (toolsForPrompt: typeof combinedInitialTools, mcpClientsForPrompt: MCPServerConnection[], model: string): Promise<TurnContextSnapshot> => {
|
||||
const additionalWorkingDirectories = Array.from(toolPermissionContext.additionalWorkingDirectories.keys());
|
||||
const cacheKey = buildTurnContextCacheKey(model, toolsForPrompt, additionalWorkingDirectories, mcpClientsForPrompt);
|
||||
const cached = turnContextCacheRef.current;
|
||||
if (cached?.key === cacheKey) {
|
||||
return cached;
|
||||
}
|
||||
const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([getSystemPrompt(toolsForPrompt, model, additionalWorkingDirectories, mcpClientsForPrompt), getUserContext(), getSystemContext()]);
|
||||
const next = {
|
||||
key: cacheKey,
|
||||
defaultSystemPrompt,
|
||||
userContext,
|
||||
systemContext
|
||||
};
|
||||
turnContextCacheRef.current = next;
|
||||
return next;
|
||||
}, [toolPermissionContext]);
|
||||
|
||||
// Session backgrounding (Ctrl+B to background/foreground)
|
||||
const handleBackgroundQuery = useCallback(() => {
|
||||
@@ -2542,7 +2593,11 @@ export function REPL({
|
||||
const removedNotifications = removeByFilter(cmd => cmd.mode === 'task-notification');
|
||||
void (async () => {
|
||||
const toolUseContext = getToolUseContext(messagesRef.current, [], new AbortController(), mainLoopModel);
|
||||
const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([getSystemPrompt(toolUseContext.options.tools, mainLoopModel, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), toolUseContext.options.mcpClients), getUserContext(), getSystemContext()]);
|
||||
const {
|
||||
defaultSystemPrompt,
|
||||
userContext,
|
||||
systemContext
|
||||
} = await loadTurnContext(toolUseContext.options.tools, toolUseContext.options.mcpClients, mainLoopModel);
|
||||
const systemPrompt = buildEffectiveSystemPrompt({
|
||||
mainThreadAgentDefinition,
|
||||
toolUseContext,
|
||||
@@ -2581,7 +2636,7 @@ export function REPL({
|
||||
agentDefinition: mainThreadAgentDefinition
|
||||
});
|
||||
})();
|
||||
}, [abortController, mainLoopModel, toolPermissionContext, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState]);
|
||||
}, [abortController, mainLoopModel, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState, loadTurnContext]);
|
||||
const {
|
||||
handleBackgroundSession
|
||||
} = useSessionBackgrounding({
|
||||
@@ -2775,11 +2830,16 @@ export function REPL({
|
||||
});
|
||||
}
|
||||
queryCheckpoint('query_context_loading_start');
|
||||
const [,, defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([
|
||||
const [,, turnContext] = await Promise.all([
|
||||
// IMPORTANT: do this after setMessages() above, to avoid UI jank
|
||||
checkAndDisableBypassPermissionsIfNeeded(toolPermissionContext, setAppState),
|
||||
// Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in
|
||||
feature('TRANSCRIPT_CLASSIFIER') ? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode) : undefined, getSystemPrompt(freshTools, mainLoopModelParam, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), freshMcpClients), getUserContext(), getSystemContext()]);
|
||||
feature('TRANSCRIPT_CLASSIFIER') ? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode) : undefined, loadTurnContext(freshTools, freshMcpClients, mainLoopModelParam)]);
|
||||
const {
|
||||
defaultSystemPrompt,
|
||||
userContext: baseUserContext,
|
||||
systemContext
|
||||
} = turnContext;
|
||||
const userContext = {
|
||||
...baseUserContext,
|
||||
...getCoordinatorUserContext(freshMcpClients, isScratchpadEnabled() ? getScratchpadDir() : undefined),
|
||||
@@ -2861,7 +2921,7 @@ export function REPL({
|
||||
|
||||
// Signal that a query turn has completed successfully
|
||||
await onTurnComplete?.(messagesRef.current);
|
||||
}, [initialMcpClients, resetLoadingState, getToolUseContext, toolPermissionContext, setAppState, customSystemPrompt, onTurnComplete, appendSystemPrompt, canUseTool, mainThreadAgentDefinition, onQueryEvent, sessionTitle, titleDisabled]);
|
||||
}, [initialMcpClients, resetLoadingState, getToolUseContext, toolPermissionContext, setAppState, customSystemPrompt, onTurnComplete, appendSystemPrompt, canUseTool, mainThreadAgentDefinition, onQueryEvent, sessionTitle, titleDisabled, loadTurnContext]);
|
||||
const onQuery = useCallback(async (newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, onBeforeQueryCallback?: (input: string, newMessages: MessageType[]) => Promise<boolean>, input?: string, effort?: EffortValue): Promise<void> => {
|
||||
// If this is a teammate, mark them as active when starting a turn
|
||||
if (isAgentSwarmsEnabled()) {
|
||||
@@ -4941,8 +5001,11 @@ export function REPL({
|
||||
}
|
||||
const newAbortController = createAbortController();
|
||||
const context = getToolUseContext(compactMessages, [], newAbortController, mainLoopModel);
|
||||
const appState = context.getAppState();
|
||||
const defaultSysPrompt = await getSystemPrompt(context.options.tools, context.options.mainLoopModel, Array.from(appState.toolPermissionContext.additionalWorkingDirectories.keys()), context.options.mcpClients);
|
||||
const {
|
||||
defaultSystemPrompt: defaultSysPrompt,
|
||||
userContext,
|
||||
systemContext
|
||||
} = await loadTurnContext(context.options.tools, context.options.mcpClients, context.options.mainLoopModel);
|
||||
const systemPrompt = buildEffectiveSystemPrompt({
|
||||
mainThreadAgentDefinition: undefined,
|
||||
toolUseContext: context,
|
||||
@@ -4950,7 +5013,6 @@ export function REPL({
|
||||
defaultSystemPrompt: defaultSysPrompt,
|
||||
appendSystemPrompt: context.options.appendSystemPrompt
|
||||
});
|
||||
const [userContext, systemContext] = await Promise.all([getUserContext(), getSystemContext()]);
|
||||
const result = await partialCompactConversation(compactMessages, messageIndex, context, {
|
||||
systemPrompt,
|
||||
userContext,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { findToolByName, type Tools, type ToolUseContext } from '../../Tool.js'
|
||||
import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
|
||||
import type { AssistantMessage, Message } from '../../types/message.js'
|
||||
import { createChildAbortController } from '../../utils/abortController.js'
|
||||
import { getMaxToolUseConcurrency } from './toolConcurrency.js'
|
||||
import { runToolUse } from './toolExecution.js'
|
||||
|
||||
type MessageUpdate = {
|
||||
@@ -31,6 +32,20 @@ type TrackedTool = {
|
||||
contextModifiers?: Array<(context: ToolUseContext) => ToolUseContext>
|
||||
}
|
||||
|
||||
const EPHEMERAL_PROGRESS_TYPES = new Set([
|
||||
'bash_progress',
|
||||
'powershell_progress',
|
||||
'mcp_progress',
|
||||
'sleep_progress',
|
||||
])
|
||||
|
||||
function isEphemeralProgressMessage(message: Message): boolean {
|
||||
return (
|
||||
message.type === 'progress' &&
|
||||
EPHEMERAL_PROGRESS_TYPES.has(message.data.type)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes tools as they stream in with concurrency control.
|
||||
* - Concurrent-safe tools can execute in parallel with other concurrent-safe tools
|
||||
@@ -128,10 +143,13 @@ export class StreamingToolExecutor {
|
||||
*/
|
||||
private canExecuteTool(isConcurrencySafe: boolean): boolean {
|
||||
const executingTools = this.tools.filter(t => t.status === 'executing')
|
||||
return (
|
||||
executingTools.length === 0 ||
|
||||
(isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
|
||||
)
|
||||
if (executingTools.length === 0) {
|
||||
return true
|
||||
}
|
||||
if (!isConcurrencySafe || !executingTools.every(t => t.isConcurrencySafe)) {
|
||||
return false
|
||||
}
|
||||
return executingTools.length < getMaxToolUseConcurrency()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,7 +384,17 @@ export class StreamingToolExecutor {
|
||||
if (update.message) {
|
||||
// Progress messages go to pendingProgress for immediate yielding
|
||||
if (update.message.type === 'progress') {
|
||||
tool.pendingProgress.push(update.message)
|
||||
const lastPending = tool.pendingProgress.at(-1)
|
||||
if (
|
||||
isEphemeralProgressMessage(update.message) &&
|
||||
lastPending?.type === 'progress' &&
|
||||
lastPending.data.type === update.message.data.type
|
||||
) {
|
||||
tool.pendingProgress[tool.pendingProgress.length - 1] =
|
||||
update.message
|
||||
} else {
|
||||
tool.pendingProgress.push(update.message)
|
||||
}
|
||||
// Signal that progress is available
|
||||
if (this.progressAvailableResolve) {
|
||||
this.progressAvailableResolve()
|
||||
|
||||
8
src/services/tools/toolConcurrency.ts
Normal file
8
src/services/tools/toolConcurrency.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const DEFAULT_MAX_TOOL_USE_CONCURRENCY = 4
|
||||
|
||||
export function getMaxToolUseConcurrency(): number {
|
||||
const parsed = parseInt(process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || '', 10)
|
||||
return Number.isFinite(parsed) && parsed > 0
|
||||
? parsed
|
||||
: DEFAULT_MAX_TOOL_USE_CONCURRENCY
|
||||
}
|
||||
33
src/services/tools/toolOrchestration.test.ts
Normal file
33
src/services/tools/toolOrchestration.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { afterEach, expect, test } from 'bun:test'
|
||||
import {
|
||||
DEFAULT_MAX_TOOL_USE_CONCURRENCY,
|
||||
getMaxToolUseConcurrency,
|
||||
} from './toolConcurrency.js'
|
||||
|
||||
const ORIGINAL_CONCURRENCY = process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_CONCURRENCY === undefined) {
|
||||
delete process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY = ORIGINAL_CONCURRENCY
|
||||
}
|
||||
})
|
||||
|
||||
test('defaults tool concurrency to a bounded budget', () => {
|
||||
delete process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY
|
||||
expect(getMaxToolUseConcurrency()).toBe(DEFAULT_MAX_TOOL_USE_CONCURRENCY)
|
||||
})
|
||||
|
||||
test('allows an explicit positive concurrency override', () => {
|
||||
process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY = '7'
|
||||
expect(getMaxToolUseConcurrency()).toBe(7)
|
||||
})
|
||||
|
||||
test('ignores invalid concurrency overrides', () => {
|
||||
process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY = '0'
|
||||
expect(getMaxToolUseConcurrency()).toBe(DEFAULT_MAX_TOOL_USE_CONCURRENCY)
|
||||
|
||||
process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY = 'not-a-number'
|
||||
expect(getMaxToolUseConcurrency()).toBe(DEFAULT_MAX_TOOL_USE_CONCURRENCY)
|
||||
})
|
||||
@@ -4,12 +4,10 @@ import { findToolByName, type ToolUseContext } from '../../Tool.js'
|
||||
import type { AssistantMessage, Message } from '../../types/message.js'
|
||||
import { all } from '../../utils/generators.js'
|
||||
import { type MessageUpdateLazy, runToolUse } from './toolExecution.js'
|
||||
|
||||
function getMaxToolUseConcurrency(): number {
|
||||
return (
|
||||
parseInt(process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || '', 10) || 10
|
||||
)
|
||||
}
|
||||
export {
|
||||
DEFAULT_MAX_TOOL_USE_CONCURRENCY,
|
||||
getMaxToolUseConcurrency,
|
||||
} from './toolConcurrency.js'
|
||||
|
||||
export type MessageUpdate = {
|
||||
message?: Message
|
||||
|
||||
@@ -10,12 +10,16 @@ import { requireComputerUseSwift } from './swiftLoader.js'
|
||||
* promises hang. Electron drains it via CFRunLoop so Cowork doesn't need this.
|
||||
*
|
||||
* One refcounted setInterval calls `_drainMainRunLoop` (RunLoop.main.run)
|
||||
* every 1ms while any main-queue-dependent call is pending. Multiple
|
||||
* every few milliseconds while any main-queue-dependent call is pending. Multiple
|
||||
* concurrent drainRunLoop() calls share the single pump via retain/release.
|
||||
*/
|
||||
|
||||
let pump: ReturnType<typeof setInterval> | undefined
|
||||
let pending = 0
|
||||
const DRAIN_INTERVAL_MS = Math.max(
|
||||
1,
|
||||
Number.parseInt(process.env.CLAUDE_CODE_COMPUTER_USE_PUMP_MS || '', 10) || 4,
|
||||
)
|
||||
|
||||
function drainTick(cu: ReturnType<typeof requireComputerUseSwift>): void {
|
||||
cu._drainMainRunLoop()
|
||||
@@ -24,7 +28,8 @@ function drainTick(cu: ReturnType<typeof requireComputerUseSwift>): void {
|
||||
function retain(): void {
|
||||
pending++
|
||||
if (pump === undefined) {
|
||||
pump = setInterval(drainTick, 1, requireComputerUseSwift())
|
||||
pump = setInterval(drainTick, DRAIN_INTERVAL_MS, requireComputerUseSwift())
|
||||
pump.unref?.()
|
||||
logForDebugging('[drainRunLoop] pump started', { level: 'verbose' })
|
||||
}
|
||||
}
|
||||
|
||||
15
src/utils/cronScheduler.test.ts
Normal file
15
src/utils/cronScheduler.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { getSchedulerCheckDelayMs } from './cronScheduler.js'
|
||||
|
||||
test('uses a slower idle cadence when no cron tasks are pending', () => {
|
||||
expect(getSchedulerCheckDelayMs(null, 10_000)).toBe(2000)
|
||||
})
|
||||
|
||||
test('keeps the minimum cadence for tasks that are due soon', () => {
|
||||
expect(getSchedulerCheckDelayMs(10_500, 10_000)).toBe(1000)
|
||||
})
|
||||
|
||||
test('backs off proportionally for tasks scheduled farther out', () => {
|
||||
expect(getSchedulerCheckDelayMs(16_000, 10_000)).toBe(3000)
|
||||
expect(getSchedulerCheckDelayMs(22_000, 10_000)).toBe(5000)
|
||||
})
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
import { logForDebugging } from './debug.js'
|
||||
|
||||
const CHECK_INTERVAL_MS = 1000
|
||||
const NO_TASKS_CHECK_DELAY_MS = 2000
|
||||
const MAX_CHECK_DELAY_MS = 5000
|
||||
const FILE_STABILITY_MS = 300
|
||||
// How often a non-owning session re-probes the scheduler lock. Coarse
|
||||
// because takeover only matters when the owning session has crashed.
|
||||
@@ -59,6 +61,31 @@ export function isRecurringTaskAged(
|
||||
return Boolean(t.recurring && !t.permanent && nowMs - t.createdAt >= maxAgeMs)
|
||||
}
|
||||
|
||||
export function getSchedulerCheckDelayMs(
|
||||
nextFireAtMs: number | null,
|
||||
nowMs: number,
|
||||
options?: {
|
||||
minMs?: number
|
||||
maxMs?: number
|
||||
noTasksDelayMs?: number
|
||||
},
|
||||
): number {
|
||||
const minMs = options?.minMs ?? CHECK_INTERVAL_MS
|
||||
const maxMs = options?.maxMs ?? MAX_CHECK_DELAY_MS
|
||||
const noTasksDelayMs = options?.noTasksDelayMs ?? NO_TASKS_CHECK_DELAY_MS
|
||||
|
||||
if (nextFireAtMs === null) {
|
||||
return noTasksDelayMs
|
||||
}
|
||||
|
||||
const untilFireMs = nextFireAtMs - nowMs
|
||||
if (untilFireMs <= minMs) {
|
||||
return minMs
|
||||
}
|
||||
|
||||
return Math.min(maxMs, Math.max(minMs, Math.floor(untilFireMs / 2)))
|
||||
}
|
||||
|
||||
type CronSchedulerOptions = {
|
||||
/** Called when a task fires (regular or missed-on-startup). */
|
||||
onFire: (prompt: string) => void
|
||||
@@ -170,7 +197,7 @@ export function createCronScheduler(
|
||||
const inFlight = new Set<string>()
|
||||
|
||||
let enablePoll: ReturnType<typeof setInterval> | null = null
|
||||
let checkTimer: ReturnType<typeof setInterval> | null = null
|
||||
let checkTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let lockProbeTimer: ReturnType<typeof setInterval> | null = null
|
||||
let watcher: FSWatcher | null = null
|
||||
let stopped = false
|
||||
@@ -189,7 +216,10 @@ export function createCronScheduler(
|
||||
// Recurring tasks are NOT surfaced or deleted — check() handles them
|
||||
// correctly (fires on first tick, reschedules forward). Only one-shot
|
||||
// missed tasks need user input (run once now, or discard forever).
|
||||
if (!initial) return
|
||||
if (!initial) {
|
||||
scheduleCheck(CHECK_INTERVAL_MS)
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const missed = findMissedTasks(next, now).filter(
|
||||
@@ -225,6 +255,7 @@ export function createCronScheduler(
|
||||
`[ScheduledTasks] surfaced ${missed.length} missed one-shot task(s)`,
|
||||
)
|
||||
}
|
||||
scheduleCheck(CHECK_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function check() {
|
||||
@@ -393,6 +424,29 @@ export function createCronScheduler(
|
||||
}
|
||||
}
|
||||
|
||||
function getNextFireTimeValue(): number | null {
|
||||
let min = Infinity
|
||||
for (const t of nextFireAt.values()) {
|
||||
if (t < min) min = t
|
||||
}
|
||||
return min === Infinity ? null : min
|
||||
}
|
||||
|
||||
function scheduleCheck(delayMs = CHECK_INTERVAL_MS): void {
|
||||
if (stopped) return
|
||||
if (checkTimer) {
|
||||
clearTimeout(checkTimer)
|
||||
checkTimer = null
|
||||
}
|
||||
checkTimer = setTimeout(() => {
|
||||
checkTimer = null
|
||||
if (stopped) return
|
||||
check()
|
||||
scheduleCheck(getSchedulerCheckDelayMs(getNextFireTimeValue(), Date.now()))
|
||||
}, delayMs)
|
||||
checkTimer.unref?.()
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
if (stopped) return
|
||||
if (enablePoll) {
|
||||
@@ -450,13 +504,11 @@ export function createCronScheduler(
|
||||
if (!stopped) {
|
||||
tasks = []
|
||||
nextFireAt.clear()
|
||||
scheduleCheck(NO_TASKS_CHECK_DELAY_MS)
|
||||
}
|
||||
})
|
||||
|
||||
checkTimer = setInterval(check, CHECK_INTERVAL_MS)
|
||||
// Don't keep the process alive for the scheduler alone — in -p text mode
|
||||
// the process should exit after the single turn even if a cron was created.
|
||||
checkTimer.unref?.()
|
||||
scheduleCheck(CHECK_INTERVAL_MS)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -503,7 +555,7 @@ export function createCronScheduler(
|
||||
enablePoll = null
|
||||
}
|
||||
if (checkTimer) {
|
||||
clearInterval(checkTimer)
|
||||
clearTimeout(checkTimer)
|
||||
checkTimer = null
|
||||
}
|
||||
if (lockProbeTimer) {
|
||||
@@ -521,11 +573,7 @@ export function createCronScheduler(
|
||||
// nextFireAt uses Infinity for "never" (in-flight one-shots, bad cron
|
||||
// strings). Filter those out so callers can distinguish "soon" from
|
||||
// "nothing pending".
|
||||
let min = Infinity
|
||||
for (const t of nextFireAt.values()) {
|
||||
if (t < min) min = t
|
||||
}
|
||||
return min === Infinity ? null : min
|
||||
return getNextFireTimeValue()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,13 @@
|
||||
* - query_end: End of query
|
||||
*/
|
||||
|
||||
import { join } from 'path'
|
||||
import { getSessionId } from 'src/bootstrap/state.js'
|
||||
import { logForDebugging } from './debug.js'
|
||||
import { isEnvTruthy } from './envUtils.js'
|
||||
import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
|
||||
import { getFsImplementation } from './fsOperations.js'
|
||||
import { formatMs, formatTimelineLine, getPerformance } from './profilerBase.js'
|
||||
import { writeFileSync_DEPRECATED } from './slowOperations.js'
|
||||
|
||||
// Module-level state - initialized once when the module loads
|
||||
// eslint-disable-next-line custom-rules/no-process-env-top-level
|
||||
@@ -210,6 +214,14 @@ function getQueryProfileReport(): string {
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function getQueryPerfLogPath(queryNumber = queryCount): string {
|
||||
return join(
|
||||
getClaudeConfigHomeDir(),
|
||||
'query-perf',
|
||||
`${getSessionId()}-q${queryNumber}.txt`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get phase-based summary showing time spent in each major phase
|
||||
*/
|
||||
@@ -297,5 +309,17 @@ function getPhaseSummary(
|
||||
*/
|
||||
export function logQueryProfileReport(): void {
|
||||
if (!ENABLED) return
|
||||
logForDebugging(getQueryProfileReport())
|
||||
const report = getQueryProfileReport()
|
||||
logForDebugging(report)
|
||||
|
||||
try {
|
||||
const path = getQueryPerfLogPath()
|
||||
getFsImplementation().mkdirSync(join(getClaudeConfigHomeDir(), 'query-perf'))
|
||||
writeFileSync_DEPRECATED(path, report, {
|
||||
encoding: 'utf8',
|
||||
flush: true,
|
||||
})
|
||||
} catch {
|
||||
// Best-effort artifact write. Debug output above still preserves the report.
|
||||
}
|
||||
}
|
||||
|
||||
19
src/utils/task/TaskOutput.test.ts
Normal file
19
src/utils/task/TaskOutput.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { getNextTaskOutputPollIntervalMs } from './taskOutputPolling.js'
|
||||
|
||||
test('resets task output polling to the active interval on new activity', () => {
|
||||
expect(getNextTaskOutputPollIntervalMs(4000, true)).toBe(250)
|
||||
})
|
||||
|
||||
test('backs task output polling off when the command is quiet', () => {
|
||||
let intervalMs = getNextTaskOutputPollIntervalMs(250, false)
|
||||
expect(intervalMs).toBe(1000)
|
||||
|
||||
intervalMs = getNextTaskOutputPollIntervalMs(intervalMs, false)
|
||||
expect(intervalMs).toBe(2000)
|
||||
|
||||
intervalMs = getNextTaskOutputPollIntervalMs(intervalMs, false)
|
||||
expect(intervalMs).toBe(4000)
|
||||
|
||||
expect(getNextTaskOutputPollIntervalMs(intervalMs, false)).toBe(4000)
|
||||
})
|
||||
@@ -5,9 +5,12 @@ import { readFileRange, tailFile } from '../fsOperations.js'
|
||||
import { getMaxOutputLength } from '../shell/outputLimits.js'
|
||||
import { safeJoinLines } from '../stringUtils.js'
|
||||
import { DiskTaskOutput, getTaskOutputPath } from './diskOutput.js'
|
||||
import {
|
||||
ACTIVE_TASK_OUTPUT_POLL_INTERVAL_MS,
|
||||
getNextTaskOutputPollIntervalMs,
|
||||
} from './taskOutputPolling.js'
|
||||
|
||||
const DEFAULT_MAX_MEMORY = 8 * 1024 * 1024 // 8MB
|
||||
const POLL_INTERVAL_MS = 1000
|
||||
const PROGRESS_TAIL_BYTES = 4096
|
||||
|
||||
type ProgressCallback = (
|
||||
@@ -54,6 +57,8 @@ export class TaskOutput {
|
||||
/** Subset of #registry currently being polled (visibility-driven by React). */
|
||||
static #activePolling = new Map<string, TaskOutput>()
|
||||
static #pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
static #pollIntervalMs = ACTIVE_TASK_OUTPUT_POLL_INTERVAL_MS
|
||||
static #tickInFlight = false
|
||||
|
||||
constructor(
|
||||
taskId: string,
|
||||
@@ -84,10 +89,7 @@ export class TaskOutput {
|
||||
return
|
||||
}
|
||||
TaskOutput.#activePolling.set(taskId, instance)
|
||||
if (!TaskOutput.#pollInterval) {
|
||||
TaskOutput.#pollInterval = setInterval(TaskOutput.#tick, POLL_INTERVAL_MS)
|
||||
TaskOutput.#pollInterval.unref()
|
||||
}
|
||||
TaskOutput.#setPollInterval(ACTIVE_TASK_OUTPUT_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,20 +101,52 @@ export class TaskOutput {
|
||||
if (TaskOutput.#activePolling.size === 0 && TaskOutput.#pollInterval) {
|
||||
clearInterval(TaskOutput.#pollInterval)
|
||||
TaskOutput.#pollInterval = null
|
||||
TaskOutput.#pollIntervalMs = ACTIVE_TASK_OUTPUT_POLL_INTERVAL_MS
|
||||
}
|
||||
}
|
||||
|
||||
static #setPollInterval(intervalMs: number): void {
|
||||
TaskOutput.#pollIntervalMs = intervalMs
|
||||
if (TaskOutput.#pollInterval) {
|
||||
clearInterval(TaskOutput.#pollInterval)
|
||||
TaskOutput.#pollInterval = null
|
||||
}
|
||||
if (TaskOutput.#activePolling.size === 0) {
|
||||
return
|
||||
}
|
||||
TaskOutput.#pollInterval = setInterval(TaskOutput.#tick, intervalMs)
|
||||
TaskOutput.#pollInterval.unref()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared tick: reads the file tail for every actively-polled task.
|
||||
* Non-async body (.then) to avoid stacking if I/O is slow.
|
||||
* Skip overlapping ticks so slow I/O does not stack more work.
|
||||
*/
|
||||
static #tick(): void {
|
||||
for (const [, entry] of TaskOutput.#activePolling) {
|
||||
if (!entry.#onProgress) {
|
||||
continue
|
||||
}
|
||||
void tailFile(entry.path, PROGRESS_TAIL_BYTES).then(
|
||||
({ content, bytesRead, bytesTotal }) => {
|
||||
if (TaskOutput.#tickInFlight) {
|
||||
return
|
||||
}
|
||||
TaskOutput.#tickInFlight = true
|
||||
void TaskOutput.#runTick().finally(() => {
|
||||
TaskOutput.#tickInFlight = false
|
||||
})
|
||||
}
|
||||
|
||||
static async #runTick(): Promise<void> {
|
||||
let sawActivity = false
|
||||
await Promise.all(
|
||||
Array.from(TaskOutput.#activePolling.values(), async entry => {
|
||||
if (!entry.#onProgress) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { content, bytesRead, bytesTotal } = await tailFile(
|
||||
entry.path,
|
||||
PROGRESS_TAIL_BYTES,
|
||||
)
|
||||
const bytesChanged = bytesTotal !== entry.#totalBytes
|
||||
sawActivity = sawActivity || bytesChanged
|
||||
|
||||
if (!entry.#onProgress) {
|
||||
return
|
||||
}
|
||||
@@ -120,6 +154,7 @@ export class TaskOutput {
|
||||
// progress loop wakes up and can check for backgrounding.
|
||||
// Commands like `git log -S` produce no output for long periods.
|
||||
if (!content) {
|
||||
entry.#totalBytes = bytesTotal
|
||||
entry.#onProgress('', '', entry.#totalLines, bytesTotal, false)
|
||||
return
|
||||
}
|
||||
@@ -155,11 +190,18 @@ export class TaskOutput {
|
||||
bytesTotal,
|
||||
bytesRead < bytesTotal,
|
||||
)
|
||||
},
|
||||
() => {
|
||||
} catch {
|
||||
// File may not exist yet
|
||||
},
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const nextIntervalMs = getNextTaskOutputPollIntervalMs(
|
||||
TaskOutput.#pollIntervalMs,
|
||||
sawActivity,
|
||||
)
|
||||
if (nextIntervalMs !== TaskOutput.#pollIntervalMs) {
|
||||
TaskOutput.#setPollInterval(nextIntervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16
src/utils/task/taskOutputPolling.ts
Normal file
16
src/utils/task/taskOutputPolling.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const ACTIVE_TASK_OUTPUT_POLL_INTERVAL_MS = 250
|
||||
const IDLE_TASK_OUTPUT_POLL_INTERVAL_MS = 1000
|
||||
const MAX_TASK_OUTPUT_POLL_INTERVAL_MS = 4000
|
||||
|
||||
export function getNextTaskOutputPollIntervalMs(
|
||||
currentIntervalMs: number,
|
||||
sawActivity: boolean,
|
||||
): number {
|
||||
if (sawActivity) {
|
||||
return ACTIVE_TASK_OUTPUT_POLL_INTERVAL_MS
|
||||
}
|
||||
if (currentIntervalMs < IDLE_TASK_OUTPUT_POLL_INTERVAL_MS) {
|
||||
return IDLE_TASK_OUTPUT_POLL_INTERVAL_MS
|
||||
}
|
||||
return Math.min(MAX_TASK_OUTPUT_POLL_INTERVAL_MS, currentIntervalMs * 2)
|
||||
}
|
||||
Reference in New Issue
Block a user