Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions plugin/scripts/context-hook.js

Large diffs are not rendered by default.

30 changes: 15 additions & 15 deletions plugin/scripts/new-hook.js

Large diffs are not rendered by default.

22 changes: 11 additions & 11 deletions plugin/scripts/save-hook.js

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions plugin/scripts/summary-hook.js

Large diffs are not rendered by default.

22 changes: 11 additions & 11 deletions plugin/scripts/user-message-hook.js

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion src/hooks/context-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ensureWorkerRunning, getWorkerPort } from "../shared/worker-utils.js";
import { HOOK_TIMEOUTS } from "../shared/hook-constants.js";
import { getProjectName } from "../utils/project-name.js";
import { logger } from "../utils/logger.js";
import { fetchWithRetry } from "../shared/fetch-with-retry.js";

export interface SessionStartInput {
session_id: string;
Expand All @@ -31,7 +32,8 @@ async function contextHook(input?: SessionStartInput): Promise<string> {

// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)
// Worker service has its own timeouts, so client-side timeout is redundant
const response = await fetch(url);
// Uses fetchWithRetry to handle transient ECONNRESET errors during worker restarts
const response = await fetchWithRetry(url);

if (!response.ok) {
throw new Error(`Context generation failed: ${response.status}`);
Expand Down
7 changes: 5 additions & 2 deletions src/hooks/new-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { STANDARD_HOOK_RESPONSE } from './hook-response.js';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
import { getProjectName } from '../utils/project-name.js';
import { logger } from '../utils/logger.js';
import { fetchWithRetry } from '../shared/fetch-with-retry.js';

export interface UserPromptSubmitInput {
session_id: string;
Expand Down Expand Up @@ -32,7 +33,8 @@ async function newHook(input?: UserPromptSubmitInput): Promise<void> {
logger.info('HOOK', 'new-hook: Calling /api/sessions/init', { contentSessionId: session_id, project, prompt_length: prompt?.length });

// Initialize session via HTTP - handles DB operations and privacy checks
const initResponse = await fetch(`http://127.0.0.1:${port}/api/sessions/init`, {
// Uses fetchWithRetry to handle transient ECONNRESET errors during worker restarts
const initResponse = await fetchWithRetry(`http://127.0.0.1:${port}/api/sessions/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Expand Down Expand Up @@ -69,7 +71,8 @@ async function newHook(input?: UserPromptSubmitInput): Promise<void> {
logger.info('HOOK', 'new-hook: Calling /sessions/{sessionDbId}/init', { sessionDbId, promptNumber, userPrompt_length: cleanedPrompt?.length });

// Initialize SDK agent session via HTTP (starts the agent!)
const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/init`, {
// Uses fetchWithRetry to handle transient ECONNRESET errors during worker restarts
const response = await fetchWithRetry(`http://127.0.0.1:${port}/sessions/${sessionDbId}/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userPrompt: cleanedPrompt, promptNumber })
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/save-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { STANDARD_HOOK_RESPONSE } from './hook-response.js';
import { logger } from '../utils/logger.js';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';
import { fetchWithRetry } from '../shared/fetch-with-retry.js';

export interface PostToolUseInput {
session_id: string;
Expand Down Expand Up @@ -47,7 +48,8 @@ async function saveHook(input?: PostToolUseInput): Promise<void> {
}

// Send to worker - worker handles privacy check and database operations
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
// Uses fetchWithRetry to handle transient ECONNRESET errors during worker restarts
const response = await fetchWithRetry(`http://127.0.0.1:${port}/api/sessions/observations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/summary-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { logger } from '../utils/logger.js';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';
import { extractLastMessage } from '../shared/transcript-parser.js';
import { fetchWithRetry } from '../shared/fetch-with-retry.js';

export interface StopInput {
session_id: string;
Expand Down Expand Up @@ -53,7 +54,8 @@ async function summaryHook(input?: StopInput): Promise<void> {
});

// Send to worker - worker handles privacy check and database operations
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/summarize`, {
// Uses fetchWithRetry to handle transient ECONNRESET errors during worker restarts
const response = await fetchWithRetry(`http://127.0.0.1:${port}/api/sessions/summarize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/user-message-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { basename } from "path";
import { ensureWorkerRunning, getWorkerPort } from "../shared/worker-utils.js";
import { HOOK_EXIT_CODES } from "../shared/hook-constants.js";
import { logger } from "../utils/logger.js";
import { fetchWithRetry } from "../shared/fetch-with-retry.js";

// Ensure worker is running
await ensureWorkerRunning();
Expand All @@ -19,7 +20,8 @@ const project = basename(process.cwd());

// Fetch formatted context directly from worker API
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
const response = await fetch(
// Uses fetchWithRetry to handle transient ECONNRESET errors during worker restarts
const response = await fetchWithRetry(
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}&colors=true`,
{ method: 'GET' }
);
Expand Down
70 changes: 70 additions & 0 deletions src/shared/fetch-with-retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Fetch with retry for transient network errors
*
* Handles ECONNRESET and ECONNREFUSED which occur when the worker
* is restarting or multiple sessions race to connect.
*/

import { logger } from '../utils/logger.js';

const TRANSIENT_ERROR_CODES = ['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT'];

interface RetryOptions {
retries?: number;
baseDelayMs?: number;
maxDelayMs?: number;
}

/**
* Check if an error is a transient network error that should be retried
*/
function isTransientError(error: unknown): boolean {
if (error instanceof Error) {
const cause = (error as Error & { cause?: { code?: string } }).cause;
if (cause?.code && TRANSIENT_ERROR_CODES.includes(cause.code)) {
return true;
}
// Also check the error message for these codes
if (TRANSIENT_ERROR_CODES.some(code => error.message.includes(code))) {
return true;
}
}
return false;
}

/**
* Fetch with automatic retry for transient network errors
* Uses exponential backoff: 100ms, 200ms, 400ms by default
*/
export async function fetchWithRetry(
url: string,
options?: RequestInit,
retryOptions?: RetryOptions
): Promise<Response> {
const { retries = 3, baseDelayMs = 100, maxDelayMs = 1000 } = retryOptions ?? {};

let lastError: Error | undefined;

for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fetch(url, options);
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));

if (attempt < retries && isTransientError(error)) {
const delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
logger.debug('FETCH', `Transient error, retrying in ${delay}ms`, {
attempt: attempt + 1,
maxRetries: retries,
errorCode: (error as Error & { cause?: { code?: string } }).cause?.code
});
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}

throw lastError;
}
}

throw lastError ?? new Error('fetchWithRetry failed without error');
}
Loading