diff --git a/.claude/skills/setup-agent-team/trigger-server.ts b/.claude/skills/setup-agent-team/trigger-server.ts index ba459449..65438be7 100644 --- a/.claude/skills/setup-agent-team/trigger-server.ts +++ b/.claude/skills/setup-agent-team/trigger-server.ts @@ -190,6 +190,59 @@ function gracefulShutdown(signal: string) { process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); process.on("SIGINT", () => gracefulShutdown("SIGINT")); +/** + * Create a safe enqueue function that gracefully handles client disconnect. + */ +function createEnqueuer(encoder: TextEncoder): { + enqueue: (controller: ReadableStreamDefaultController, chunk: Uint8Array) => void; + isConnected: () => boolean; + setDisconnected: () => void; +} { + let clientConnected = true; + + return { + enqueue(controller: ReadableStreamDefaultController, chunk: Uint8Array) { + if (!clientConnected) return; + try { + controller.enqueue(chunk); + } catch { + clientConnected = false; + } + }, + isConnected() { + return clientConnected; + }, + setDisconnected() { + clientConnected = false; + }, + }; +} + +/** + * Drain a readable stream, logging to console and enqueuing to HTTP response. + */ +async function drainStreamOutput( + src: ReadableStream | null, + enqueue: (chunk: Uint8Array) => void, + onActivity: () => void +): Promise { + if (!src) return; + const reader = src.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + // Always log locally + process.stdout.write(value); + // Stream to HTTP client if still connected + onActivity(); + enqueue(value); + } + } finally { + reader.releaseLock(); + } +} + /** * Spawn the target script and return a streaming Response. * @@ -237,49 +290,37 @@ function startStreamingRun(reason: string, issue: string): Response { }); const encoder = new TextEncoder(); - let clientConnected = true; + const enqueuer = createEnqueuer(encoder); const stream = new ReadableStream({ async start(controller) { // --- Header --- const header = `[trigger] Run #${id} started (reason=${reason}${issue ? `, issue=#${issue}` : ""}, concurrent=${runs.size}/${MAX_CONCURRENT})\n`; - enqueue(controller, encoder.encode(header)); + enqueuer.enqueue(controller, encoder.encode(header)); // --- Heartbeat: emit every 15s of silence to keep connection alive --- // Must fire well before Bun's idleTimeout (255s) and any proxy timeouts. let lastActivity = Date.now(); const heartbeat = setInterval(() => { - if (!clientConnected) return; + if (!enqueuer.isConnected()) return; const silentMs = Date.now() - lastActivity; if (silentMs >= 14_000) { const elapsed = Math.round((Date.now() - startedAt) / 1000); const msg = `[heartbeat] Run #${id} active (${elapsed}s elapsed)\n`; - enqueue(controller, encoder.encode(msg)); + enqueuer.enqueue(controller, encoder.encode(msg)); lastActivity = Date.now(); } }, 15_000); - // --- Drain stdout + stderr concurrently --- - async function drain(src: ReadableStream | null) { - if (!src) return; - const reader = src.getReader(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - // Always log locally - process.stdout.write(value); - // Stream to HTTP client if still connected - lastActivity = Date.now(); - enqueue(controller, value); - } - } finally { - reader.releaseLock(); - } - } - try { - await Promise.all([drain(proc.stdout), drain(proc.stderr)]); + await Promise.all([ + drainStreamOutput(proc.stdout, (chunk) => enqueuer.enqueue(controller, chunk), () => { + lastActivity = Date.now(); + }), + drainStreamOutput(proc.stderr, (chunk) => enqueuer.enqueue(controller, chunk), () => { + lastActivity = Date.now(); + }), + ]); // --- Wait for exit --- const exitCode = await proc.exited; @@ -287,12 +328,12 @@ function startStreamingRun(reason: string, issue: string): Response { const elapsed = Math.round((Date.now() - startedAt) / 1000); const footer = `\n[trigger] Run #${id} finished (exit=${exitCode}, duration=${elapsed}s, remaining=${runs.size}/${MAX_CONCURRENT})\n`; console.log(footer.trim()); - enqueue(controller, encoder.encode(footer)); + enqueuer.enqueue(controller, encoder.encode(footer)); } catch (err) { const elapsed = Math.round((Date.now() - startedAt) / 1000); const errMsg = `\n[trigger] Run #${id} stream error after ${elapsed}s: ${err}\n`; console.error(errMsg.trim()); - enqueue(controller, encoder.encode(errMsg)); + enqueuer.enqueue(controller, encoder.encode(errMsg)); } finally { runs.delete(id); clearInterval(heartbeat); @@ -304,26 +345,13 @@ function startStreamingRun(reason: string, issue: string): Response { cancel() { // Called when the HTTP client disconnects - clientConnected = false; + enqueuer.setDisconnected(); console.log( `[trigger] Client disconnected from run #${id} stream (process continues running)` ); }, }); - /** Safely enqueue data — swallow errors from client disconnect */ - function enqueue( - controller: ReadableStreamDefaultController, - chunk: Uint8Array - ) { - if (!clientConnected) return; - try { - controller.enqueue(chunk); - } catch { - clientConnected = false; - } - } - return new Response(stream, { headers: { "Content-Type": "text/plain; charset=utf-8", diff --git a/render/lib/common.sh b/render/lib/common.sh index 8f7b58bf..8677289f 100644 --- a/render/lib/common.sh +++ b/render/lib/common.sh @@ -146,6 +146,30 @@ print(json.dumps(body)) # Wait for Render service to become live # Usage: _render_wait_for_service SERVICE_ID [MAX_ATTEMPTS] +_render_print_deployment_failed_help() { + log_error "Common causes:" + log_error " - Build failure (check Docker image or build command configuration)" + log_error " - Insufficient resources for the selected instance type" + log_error " - Health check failure (service crashed during startup)" + log_error " - Application error in start command or missing runtime dependencies" + log_error " - Network/port configuration issues" + log_error "" + log_error "Debugging steps:" + log_error " 1. View deployment logs at: https://dashboard.render.com/" + log_error " 2. Check build and runtime logs for error messages" + log_error " 3. Verify service configuration (ports, env vars, start command)" + log_error " 4. Try a different region or instance type" +} + +_render_print_timeout_help() { + log_error "The service may still be deploying. You can:" + log_error " 1. Check deployment status at: https://dashboard.render.com/" + log_error " 2. View real-time deployment logs in the dashboard" + log_error " 3. Re-run the spawn command to retry" + log_error "" + log_error "If the issue persists, the service may need manual intervention via the Render dashboard." +} + _render_wait_for_service() { local service_id="$1" local max_attempts=${2:-60} @@ -168,18 +192,7 @@ _render_wait_for_service() { if [[ "$status" == "failed" ]]; then log_error "Service deployment failed with status: $status" log_error "" - log_error "Common causes:" - log_error " - Build failure (check Docker image or build command configuration)" - log_error " - Insufficient resources for the selected instance type" - log_error " - Health check failure (service crashed during startup)" - log_error " - Application error in start command or missing runtime dependencies" - log_error " - Network/port configuration issues" - log_error "" - log_error "Debugging steps:" - log_error " 1. View deployment logs at: https://dashboard.render.com/" - log_error " 2. Check build and runtime logs for error messages" - log_error " 3. Verify service configuration (ports, env vars, start command)" - log_error " 4. Try a different region or instance type" + _render_print_deployment_failed_help return 1 fi @@ -190,12 +203,7 @@ _render_wait_for_service() { log_error "Service did not become live after $max_attempts attempts" log_error "" - log_error "The service may still be deploying. You can:" - log_error " 1. Check deployment status at: https://dashboard.render.com/" - log_error " 2. View real-time deployment logs in the dashboard" - log_error " 3. Re-run the spawn command to retry" - log_error "" - log_error "If the issue persists, the service may need manual intervention via the Render dashboard." + _render_print_timeout_help return 1 } diff --git a/shared/common.sh b/shared/common.sh index a1cc050c..3aba4751 100644 --- a/shared/common.sh +++ b/shared/common.sh @@ -2596,62 +2596,64 @@ EOF # Display a numbered list and read user selection # Pipe-delimited items: "id|label". Returns selected id via stdout. # Usage: _display_and_select PROMPT_TEXT DEFAULT_VALUE DEFAULT_ID <<< "$items" -_display_and_select() { +_fzf_select() { local prompt_text="${1}" local default_value="${2}" - local default_id="${3:-}" + local default_id="${3}" + local fzf_input="${4}" + local default_line="${5}" - # Read all items into array - local items_array=() - while IFS= read -r line; do - items_array+=("${line}") - done + log_step "Select ${prompt_text%s} (type to filter):" - if [[ "${#items_array[@]}" -eq 0 ]]; then - log_warn "No ${prompt_text} available, using default: ${default_value}" + # Run fzf with default selection + local selected + if [[ -n "${default_line}" ]]; then + selected=$(printf '%s' "${fzf_input}" | fzf --height=~50% --reverse --prompt="Select > " --query="" --select-1 --exit-0 --header="Press ESC to use default (${default_id})" --print-query --query="${default_line%%$'\t'*}" | tail -1) + else + selected=$(printf '%s' "${fzf_input}" | fzf --height=~50% --reverse --prompt="Select > " --select-1 --exit-0) + fi + + # If fzf was cancelled or returned nothing, use default + if [[ -z "${selected}" ]]; then + log_info "Using default: ${default_value}" echo "${default_value}" return fi - # Try to use fzf for interactive filtering if available and stdin is a TTY - if command -v fzf >/dev/null 2>&1 && [[ -t 0 ]]; then - log_step "Select ${prompt_text%s} (type to filter):" + # Extract ID from selected line + local selected_id="${selected%%$'\t'*}" + echo "${selected_id}" +} - # Prepare fzf input with formatted display - local fzf_input="" - local default_line="" - for line in "${items_array[@]}"; do - local id="${line%%|*}" - local display - display=$(echo "${line}" | tr '|' '\t') - fzf_input+="${display}"$'\n' - if [[ -n "${default_id}" && "${id}" == "${default_id}" ]]; then - default_line="${display}" - fi - done +_prepare_fzf_input() { + local default_id="${1}" + shift + local items_array=("$@") - # Run fzf with default selection - local selected - if [[ -n "${default_line}" ]]; then - selected=$(printf '%s' "${fzf_input}" | fzf --height=~50% --reverse --prompt="Select > " --query="" --select-1 --exit-0 --header="Press ESC to use default (${default_id})" --print-query --query="${default_line%%$'\t'*}" | tail -1) - else - selected=$(printf '%s' "${fzf_input}" | fzf --height=~50% --reverse --prompt="Select > " --select-1 --exit-0) + local fzf_input="" + local default_line="" + for line in "${items_array[@]}"; do + local id="${line%%|*}" + local display + display=$(echo "${line}" | tr '|' '\t') + fzf_input+="${display}"$'\n' + if [[ -n "${default_id}" && "${id}" == "${default_id}" ]]; then + default_line="${display}" fi + done - # If fzf was cancelled or returned nothing, use default - if [[ -z "${selected}" ]]; then - log_info "Using default: ${default_value}" - echo "${default_value}" - return - fi + # Return via globals (bash doesn't have good multi-value returns) + FZF_INPUT="${fzf_input}" + FZF_DEFAULT_LINE="${default_line}" +} - # Extract ID from selected line - local selected_id="${selected%%$'\t'*}" - echo "${selected_id}" - return - fi +_numbered_list_select() { + local prompt_text="${1}" + local default_value="${2}" + local default_id="${3}" + shift 3 + local items_array=("$@") - # Fallback to numbered list when fzf is not available log_step "Available ${prompt_text}:" local i=1 local ids=() @@ -2679,6 +2681,34 @@ _display_and_select() { fi } +_display_and_select() { + local prompt_text="${1}" + local default_value="${2}" + local default_id="${3:-}" + + # Read all items into array + local items_array=() + while IFS= read -r line; do + items_array+=("${line}") + done + + if [[ "${#items_array[@]}" -eq 0 ]]; then + log_warn "No ${prompt_text} available, using default: ${default_value}" + echo "${default_value}" + return + fi + + # Try to use fzf for interactive filtering if available and stdin is a TTY + if command -v fzf >/dev/null 2>&1 && [[ -t 0 ]]; then + _prepare_fzf_input "${default_id}" "${items_array[@]}" + _fzf_select "${prompt_text}" "${default_value}" "${default_id}" "${FZF_INPUT}" "${FZF_DEFAULT_LINE}" + return + fi + + # Fallback to numbered list when fzf is not available + _numbered_list_select "${prompt_text}" "${default_value}" "${default_id}" "${items_array[@]}" +} + # Returns: selected ID via stdout interactive_pick() { local env_var_name="${1}"