1015 lines
31 KiB
Bash
Executable File
1015 lines
31 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# career-ops batch runner — standalone orchestrator for claude -p workers
|
|
# Reads batch-input.tsv, delegates each offer to a claude -p worker,
|
|
# tracks state in batch-state.tsv for resumability.
|
|
#
|
|
# NOTE: This script is Claude Code-specific. It uses claude -p with
|
|
# --dangerously-skip-permissions and --append-system-prompt-file flags
|
|
# that are not available in other CLIs. Multi-CLI support is out of scope
|
|
# for now — contributions welcome.
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
BATCH_DIR="$SCRIPT_DIR"
|
|
INPUT_FILE="$BATCH_DIR/batch-input.tsv"
|
|
STATE_FILE="$BATCH_DIR/batch-state.tsv"
|
|
PROMPT_FILE="$BATCH_DIR/batch-prompt.md"
|
|
PROFILE_FILE="$PROJECT_DIR/config/profile.yml"
|
|
LOGS_DIR="$BATCH_DIR/logs"
|
|
DISCARD_LOG="$LOGS_DIR/discard.log"
|
|
TRACKER_DIR="$BATCH_DIR/tracker-additions"
|
|
REPORTS_DIR="$PROJECT_DIR/reports"
|
|
APPLICATIONS_FILE="$PROJECT_DIR/data/applications.md"
|
|
LOCK_FILE="$BATCH_DIR/batch-runner.pid"
|
|
PAUSE_FILE="$BATCH_DIR/batch-runner.paused"
|
|
STATE_LOCK_DIR="$BATCH_DIR/.batch-state.lock"
|
|
STATE_LOCK_PID_FILE="$STATE_LOCK_DIR/pid"
|
|
STATE_LOCK_TIMEOUT_SECONDS=30
|
|
MAIN_PID="${BASHPID:-$$}"
|
|
|
|
# Defaults
|
|
PARALLEL=1
|
|
DRY_RUN=false
|
|
RETRY_FAILED=false
|
|
RESUME_PAUSED=false
|
|
START_FROM=0
|
|
MAX_RETRIES=2
|
|
MIN_SCORE=0
|
|
SKIP_PDF=false
|
|
MODEL="" # explicit override; otherwise resolved from config/profile.yml spend_tier
|
|
RESOLVED_MODEL=""
|
|
RESOLVED_SPEND_TIER=""
|
|
RATE_LIMIT_SLEEP=300
|
|
BATCH_PAUSED=false
|
|
STATUS_ONLY=false
|
|
WATCH_MODE=false
|
|
LIMIT=0
|
|
|
|
# Return success for non-negative integer or decimal strings.
|
|
is_decimal_number() {
|
|
[[ "$1" =~ ^[0-9]+([.][0-9]+)?$ ]]
|
|
}
|
|
|
|
usage() {
|
|
cat <<'USAGE'
|
|
career-ops batch runner — process job offers in batch via claude -p workers
|
|
Uses spend_tier from config/profile.yml unless --model overrides it.
|
|
|
|
Usage: batch-runner.sh [OPTIONS]
|
|
|
|
Options:
|
|
--parallel N Number of parallel workers (default: 1)
|
|
--dry-run Show what would be processed, don't execute
|
|
--retry-failed Only retry offers marked as "failed" in state
|
|
--resume-paused Resume offers paused by a Claude session/rate limit
|
|
--start-from N Start from offer ID N (skip earlier IDs)
|
|
--limit N Max number of offers to process in this run
|
|
--max-retries N Max retry attempts per offer (default: 2)
|
|
--min-score N Skip PDF/tracker for offers scoring below N (default: 0 = off)
|
|
--skip-pdf Skip PDF generation entirely (write ❌ in tracker PDF column)
|
|
--rate-limit-sleep N Seconds to wait before retrying a rate-limited worker
|
|
(default: 300)
|
|
--model NAME Override the tier-resolved Claude model passed to
|
|
`claude -p --model` (otherwise uses config/profile.yml
|
|
spend_tier: economy/standard/premium; default standard)
|
|
--status Show batch progress and a per-job table, then exit
|
|
--watch Live-refresh progress until the run completes
|
|
-h, --help Show this help
|
|
|
|
Files:
|
|
batch-input.tsv Input offers (id, url, source, notes)
|
|
batch-state.tsv Processing state (auto-managed)
|
|
batch-prompt.md Prompt template for workers
|
|
logs/ Per-offer logs
|
|
tracker-additions/ Tracker lines for post-batch merge
|
|
|
|
Examples:
|
|
# Dry run to see pending offers
|
|
./batch-runner.sh --dry-run
|
|
|
|
# Process all pending
|
|
./batch-runner.sh
|
|
|
|
# Retry only failed offers
|
|
./batch-runner.sh --retry-failed
|
|
|
|
# Process 2 at a time starting from ID 10
|
|
./batch-runner.sh --parallel 2 --start-from 10
|
|
USAGE
|
|
}
|
|
|
|
# Parse arguments
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--parallel) PARALLEL="$2"; shift 2 ;;
|
|
--dry-run) DRY_RUN=true; shift ;;
|
|
--retry-failed) RETRY_FAILED=true; shift ;;
|
|
--resume-paused) RESUME_PAUSED=true; shift ;;
|
|
--start-from) START_FROM="$2"; shift 2 ;;
|
|
--limit) LIMIT="$2"; shift 2 ;;
|
|
--max-retries) MAX_RETRIES="$2"; shift 2 ;;
|
|
--min-score) MIN_SCORE="$2"; shift 2 ;;
|
|
--skip-pdf) SKIP_PDF=true; shift ;;
|
|
--rate-limit-sleep)
|
|
[[ $# -ge 2 ]] || { echo "ERROR: --rate-limit-sleep requires an argument"; exit 1; }
|
|
RATE_LIMIT_SLEEP="$2"
|
|
shift 2
|
|
;;
|
|
--model) MODEL="$2"; shift 2 ;;
|
|
--status) STATUS_ONLY=true; shift ;;
|
|
--watch) WATCH_MODE=true; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) echo "Unknown option: $1"; usage; exit 1 ;;
|
|
esac
|
|
done
|
|
|
|
if ! [[ "$RATE_LIMIT_SLEEP" =~ ^[0-9]+$ ]]; then
|
|
echo "ERROR: --rate-limit-sleep must be a non-negative integer (seconds)."
|
|
exit 1
|
|
fi
|
|
|
|
if ! is_decimal_number "$MIN_SCORE"; then
|
|
echo "ERROR: --min-score must be a non-negative number."
|
|
exit 1
|
|
fi
|
|
|
|
if ! [[ "$LIMIT" =~ ^[0-9]+$ ]]; then
|
|
echo "ERROR: --limit must be a non-negative integer."
|
|
exit 1
|
|
fi
|
|
|
|
# Lock file to prevent double execution
|
|
acquire_lock() {
|
|
if [[ -f "$LOCK_FILE" ]]; then
|
|
local old_pid
|
|
old_pid=$(cat "$LOCK_FILE")
|
|
if kill -0 "$old_pid" 2>/dev/null; then
|
|
echo "ERROR: Another batch-runner is already running (PID $old_pid)"
|
|
echo "If this is stale, remove $LOCK_FILE"
|
|
exit 1
|
|
else
|
|
echo "WARN: Stale lock file found (PID $old_pid not running). Removing."
|
|
rm -f "$LOCK_FILE"
|
|
fi
|
|
fi
|
|
echo "$MAIN_PID" > "$LOCK_FILE"
|
|
}
|
|
|
|
release_lock() {
|
|
if [[ "${BASHPID:-$$}" != "$MAIN_PID" ]]; then
|
|
return
|
|
fi
|
|
rm -f "$LOCK_FILE"
|
|
}
|
|
|
|
trap release_lock EXIT
|
|
|
|
# Validate prerequisites
|
|
check_prerequisites() {
|
|
if [[ ! -f "$INPUT_FILE" ]]; then
|
|
echo "ERROR: $INPUT_FILE not found. Add offers first."
|
|
exit 1
|
|
fi
|
|
|
|
if [[ ! -f "$PROMPT_FILE" ]]; then
|
|
echo "ERROR: $PROMPT_FILE not found."
|
|
exit 1
|
|
fi
|
|
|
|
if ! command -v claude &>/dev/null; then
|
|
echo "ERROR: 'claude' CLI not found in PATH."
|
|
exit 1
|
|
fi
|
|
|
|
mkdir -p "$LOGS_DIR" "$TRACKER_DIR" "$REPORTS_DIR"
|
|
}
|
|
|
|
# Status/watch mode only needs prior batch state, not worker prerequisites.
|
|
check_status_prerequisites() {
|
|
if [[ ! -f "$STATE_FILE" ]]; then
|
|
echo "No state file found at $STATE_FILE"
|
|
exit 0
|
|
fi
|
|
}
|
|
|
|
# Initialize state file if it doesn't exist
|
|
init_state() {
|
|
if [[ ! -f "$STATE_FILE" ]]; then
|
|
printf 'id\turl\tstatus\tstarted_at\tcompleted_at\treport_num\tscore\terror\tretries\n' > "$STATE_FILE"
|
|
fi
|
|
}
|
|
|
|
acquire_state_lock() {
|
|
if [[ "${STATE_LOCK_DISABLED:-0}" -eq 1 ]]; then
|
|
return 0
|
|
fi
|
|
|
|
local waited=0
|
|
local max_waits=$((STATE_LOCK_TIMEOUT_SECONDS * 10))
|
|
|
|
while true; do
|
|
if mkdir "$STATE_LOCK_DIR" 2>/dev/null; then
|
|
if printf '%s\n' "${BASHPID:-$$}" > "$STATE_LOCK_PID_FILE"; then
|
|
STATE_LOCK_OWNED=1
|
|
return 0
|
|
fi
|
|
rm -f "$STATE_LOCK_PID_FILE" 2>/dev/null || true
|
|
rmdir "$STATE_LOCK_DIR" 2>/dev/null || true
|
|
echo "ERROR: Failed to initialize state lock metadata at $STATE_LOCK_DIR"
|
|
return 1
|
|
fi
|
|
|
|
if [[ ! -d "$STATE_LOCK_DIR" ]]; then
|
|
if (( PARALLEL <= 1 )); then
|
|
echo "WARN: State lock creation failed. Falling back to lock-free operation (single-worker mode)." >&2
|
|
STATE_LOCK_DISABLED=1
|
|
STATE_LOCK_OWNED=0
|
|
return 0
|
|
fi
|
|
echo "ERROR: Failed to create state lock directory $STATE_LOCK_DIR"
|
|
return 1
|
|
fi
|
|
|
|
if [[ -f "$STATE_LOCK_PID_FILE" ]]; then
|
|
local lock_pid
|
|
lock_pid=$(cat "$STATE_LOCK_PID_FILE" 2>/dev/null || true)
|
|
if [[ -n "$lock_pid" ]] && ! kill -0 "$lock_pid" 2>/dev/null; then
|
|
rm -f "$STATE_LOCK_PID_FILE"
|
|
if rmdir "$STATE_LOCK_DIR" 2>/dev/null; then
|
|
echo "WARN: Recovered stale state lock (PID $lock_pid not running)."
|
|
continue
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if (( waited >= max_waits )); then
|
|
echo "ERROR: Timed out waiting for state lock at $STATE_LOCK_DIR"
|
|
echo "If no batch-runner worker is active, remove the stale lock directory."
|
|
return 1
|
|
fi
|
|
|
|
sleep 0.1
|
|
((waited += 1))
|
|
done
|
|
}
|
|
|
|
release_state_lock() {
|
|
if [[ "${STATE_LOCK_OWNED:-0}" -ne 1 ]]; then
|
|
return
|
|
fi
|
|
rm -f "$STATE_LOCK_PID_FILE" 2>/dev/null || true
|
|
rmdir "$STATE_LOCK_DIR" 2>/dev/null || true
|
|
STATE_LOCK_OWNED=0
|
|
}
|
|
|
|
run_with_state_lock() {
|
|
acquire_state_lock || return $?
|
|
|
|
local status=0
|
|
if "$@"; then
|
|
status=0
|
|
else
|
|
status=$?
|
|
fi
|
|
|
|
release_state_lock
|
|
return "$status"
|
|
}
|
|
|
|
# Get status of an offer from state file
|
|
get_status() {
|
|
local id="$1"
|
|
if [[ ! -f "$STATE_FILE" ]]; then
|
|
echo "none"
|
|
return
|
|
fi
|
|
local status
|
|
status=$(awk -F'\t' -v id="$id" '$1 == id { print $3 }' "$STATE_FILE")
|
|
echo "${status:-none}"
|
|
}
|
|
|
|
# Get retry count for an offer
|
|
get_retries() {
|
|
local id="$1"
|
|
if [[ ! -f "$STATE_FILE" ]]; then
|
|
echo "0"
|
|
return
|
|
fi
|
|
local retries
|
|
retries=$(awk -F'\t' -v id="$id" '$1 == id { print $9 }' "$STATE_FILE")
|
|
echo "${retries:-0}"
|
|
}
|
|
|
|
# Read spend_tier from config/profile.yml. Defaults to "standard" if the key
|
|
# is absent or invalid.
|
|
read_spend_tier() {
|
|
local raw=""
|
|
|
|
if [[ -f "$PROFILE_FILE" ]]; then
|
|
raw=$(
|
|
awk -F: '
|
|
/^[[:space:]]*spend_tier[[:space:]]*:/ {
|
|
value = substr($0, index($0, ":") + 1)
|
|
print value
|
|
exit
|
|
}
|
|
' "$PROFILE_FILE"
|
|
)
|
|
raw="${raw%%#*}"
|
|
raw="${raw//$'\r'/}"
|
|
raw="${raw#"${raw%%[![:space:]]*}"}"
|
|
raw="${raw%"${raw##*[![:space:]]}"}"
|
|
case "$raw" in
|
|
\"*\") raw="${raw#\"}"; raw="${raw%\"}" ;;
|
|
\'*\') raw="${raw#\'}"; raw="${raw%\'}" ;;
|
|
esac
|
|
raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
|
|
fi
|
|
|
|
case "$raw" in
|
|
economy|standard|premium)
|
|
printf '%s\n' "$raw"
|
|
;;
|
|
"")
|
|
printf '%s\n' "standard"
|
|
;;
|
|
*)
|
|
echo "WARN: Invalid spend_tier \"$raw\" in ${PROFILE_FILE#"$PROJECT_DIR/"}; falling back to standard." >&2
|
|
printf '%s\n' "standard"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Tier -> model mapping. Keep in sync with the table in modes/_shared.md.
|
|
spend_tier_to_model() {
|
|
case "$1" in
|
|
economy) echo "claude-haiku-4-5" ;;
|
|
premium) echo "claude-opus-4-8" ;;
|
|
standard|*) echo "claude-sonnet-4-6" ;;
|
|
esac
|
|
}
|
|
|
|
# Resolve the model to pass to `claude -p --model`. --model always wins.
|
|
resolve_worker_model() {
|
|
if [[ -n "$MODEL" ]]; then
|
|
RESOLVED_MODEL="$MODEL"
|
|
RESOLVED_SPEND_TIER="override"
|
|
return 0
|
|
fi
|
|
|
|
RESOLVED_SPEND_TIER="$(read_spend_tier)"
|
|
RESOLVED_MODEL="$(spend_tier_to_model "$RESOLVED_SPEND_TIER")"
|
|
}
|
|
|
|
# Append a one-line, auditable record of a pre-screen-gate discard to
|
|
# batch/logs/discard.log (see modes/batch.md — Pre-screen gate). Format:
|
|
# {ISO8601 timestamp}\t{job id}\t{url}\t{reason}
|
|
log_discard() {
|
|
local id="$1" url="$2" reason="$3"
|
|
mkdir -p "$LOGS_DIR"
|
|
local ts
|
|
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
printf '%s\t%s\t%s\t%s\n' "$ts" "$id" "$url" "$reason" >> "$DISCARD_LOG"
|
|
}
|
|
|
|
# Calculate next report number.
|
|
# Caller must hold STATE_LOCK_DIR while this runs.
|
|
next_report_num_unlocked() {
|
|
local max_num=0
|
|
if [[ -d "$REPORTS_DIR" ]]; then
|
|
for f in "$REPORTS_DIR"/*.md; do
|
|
[[ -f "$f" ]] || continue
|
|
local basename
|
|
basename=$(basename "$f")
|
|
local num="${basename%%-*}"
|
|
num=$((10#$num)) # Remove leading zeros for arithmetic
|
|
if (( num > max_num )); then
|
|
max_num=$num
|
|
fi
|
|
done
|
|
fi
|
|
# Also check state file for assigned report numbers
|
|
if [[ -f "$STATE_FILE" ]]; then
|
|
while IFS=$'\t' read -r _ _ _ _ _ rnum _ _ _; do
|
|
[[ "$rnum" == "report_num" || "$rnum" == "-" || -z "$rnum" ]] && continue
|
|
local n=$((10#$rnum))
|
|
if (( n > max_num )); then
|
|
max_num=$n
|
|
fi
|
|
done < "$STATE_FILE"
|
|
fi
|
|
printf '%03d' $((max_num + 1))
|
|
}
|
|
|
|
# Update or insert state for an offer.
|
|
# Caller must hold STATE_LOCK_DIR while this runs.
|
|
update_state_unlocked() {
|
|
local id="$1" url="$2" status="$3" started="$4" completed="$5" report_num="$6" score="$7" error="$8" retries="$9"
|
|
|
|
if [[ ! -f "$STATE_FILE" ]]; then
|
|
init_state
|
|
fi
|
|
|
|
local tmp="$STATE_FILE.tmp"
|
|
local found=false
|
|
|
|
# Write header
|
|
head -1 "$STATE_FILE" > "$tmp"
|
|
|
|
# Process existing lines
|
|
while IFS=$'\t' read -r sid surl sstatus sstarted scompleted sreport sscore serror sretries; do
|
|
[[ "$sid" == "id" ]] && continue # skip header
|
|
if [[ "$sid" == "$id" ]]; then
|
|
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
|
"$id" "$url" "$status" "$started" "$completed" "$report_num" "$score" "$error" "$retries" >> "$tmp"
|
|
found=true
|
|
else
|
|
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
|
"$sid" "$surl" "$sstatus" "$sstarted" "$scompleted" "$sreport" "$sscore" "$serror" "$sretries" >> "$tmp"
|
|
fi
|
|
done < "$STATE_FILE"
|
|
|
|
if [[ "$found" == "false" ]]; then
|
|
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
|
"$id" "$url" "$status" "$started" "$completed" "$report_num" "$score" "$error" "$retries" >> "$tmp"
|
|
fi
|
|
|
|
mv "$tmp" "$STATE_FILE"
|
|
}
|
|
|
|
update_state() {
|
|
run_with_state_lock update_state_unlocked "$@"
|
|
}
|
|
|
|
is_rate_limit_log() {
|
|
local log_file="$1"
|
|
grep -Eiq '(rate limit|rate_limit|too many requests|429|quota exceeded|try again later|temporarily unavailable)' "$log_file"
|
|
}
|
|
|
|
is_session_limit_log() {
|
|
local log_file="$1"
|
|
grep -Eiq '(session limit|resets [0-9:]+[ap]m|usage limit|limit[[:space:]]+reached)' "$log_file"
|
|
}
|
|
|
|
mark_paused_rate_limit() {
|
|
local id="$1" url="$2" started_at="$3" report_num="$4" retries="$5" log_file="$6"
|
|
local completed_at
|
|
completed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
local error_msg
|
|
error_msg=$(tail -5 "$log_file" 2>/dev/null | tr '\n' ' ' | cut -c1-200 || echo "session/rate limit reached")
|
|
update_state "$id" "$url" "paused_rate_limit" "$started_at" "$completed_at" "$report_num" "-" "$error_msg" "$retries"
|
|
printf '%s\t%s\t%s\n' "$id" "$report_num" "$error_msg" > "$PAUSE_FILE"
|
|
BATCH_PAUSED=true
|
|
}
|
|
|
|
reserve_report_num_unlocked() {
|
|
local id="$1" url="$2" started="$3" retries="$4"
|
|
|
|
local report_num=""
|
|
if report_num=$(next_report_num_unlocked); then
|
|
update_state_unlocked "$id" "$url" "processing" "$started" "-" "$report_num" "-" "-" "$retries"
|
|
fi
|
|
|
|
printf '%s\n' "$report_num"
|
|
}
|
|
|
|
reserve_report_num() {
|
|
run_with_state_lock reserve_report_num_unlocked "$@"
|
|
}
|
|
|
|
# Process a single offer
|
|
process_offer() {
|
|
local id="$1" url="$2" source="$3" notes="$4"
|
|
|
|
local started_at
|
|
started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
local retries
|
|
retries=$(get_retries "$id")
|
|
local report_num
|
|
report_num=$(reserve_report_num "$id" "$url" "$started_at" "$retries")
|
|
local date
|
|
date=$(date +%Y-%m-%d)
|
|
# Use mktemp instead of a predictable /tmp path: a fixed name like
|
|
# /tmp/batch-jd-${id}.txt is guessable, so an attacker on a shared machine
|
|
# could pre-create it as a symlink and redirect or clobber the write.
|
|
local jd_file
|
|
jd_file="$(mktemp "${TMPDIR:-/tmp}/batch-jd-${id}.XXXXXX")"
|
|
|
|
echo "--- Processing offer #$id: $url (report $report_num, attempt $((retries + 1)))"
|
|
|
|
# Build the prompt with placeholders replaced
|
|
local prompt
|
|
if [[ "$SKIP_PDF" == "true" ]]; then
|
|
prompt="Process this job offer. Run the pipeline: A-G evaluation + report .md + tracker line. Do not generate PDF; write ❌ in the tracker PDF column and set \"pdf\": null in the final JSON."
|
|
echo " ⏭️ --skip-pdf set — skipping PDF generation for #$id ($url)"
|
|
else
|
|
prompt="Process this job offer. Run the full pipeline: A-G evaluation + report .md + optional PDF + tracker line."
|
|
fi
|
|
prompt="$prompt URL: $url"
|
|
prompt="$prompt JD file: $jd_file"
|
|
prompt="$prompt Report number: $report_num"
|
|
prompt="$prompt Date: $date"
|
|
prompt="$prompt Batch ID: $id"
|
|
|
|
local log_file="$LOGS_DIR/${report_num}-${id}.log"
|
|
|
|
# Prepare system prompt with placeholders resolved
|
|
local resolved_prompt="$BATCH_DIR/.resolved-prompt-${id}.md"
|
|
# Escape sed delimiter characters in variables to prevent substitution breakage
|
|
local esc_url esc_jd_file esc_report_num esc_date esc_id
|
|
esc_url="${url//\\/\\\\}"
|
|
esc_url="${esc_url//|/\\|}"
|
|
esc_jd_file="${jd_file//\\/\\\\}"
|
|
esc_jd_file="${esc_jd_file//|/\\|}"
|
|
esc_report_num="${report_num//|/\\|}"
|
|
esc_date="${date//|/\\|}"
|
|
esc_id="${id//|/\\|}"
|
|
sed \
|
|
-e "s|{{URL}}|${esc_url}|g" \
|
|
-e "s|{{JD_FILE}}|${esc_jd_file}|g" \
|
|
-e "s|{{REPORT_NUM}}|${esc_report_num}|g" \
|
|
-e "s|{{DATE}}|${esc_date}|g" \
|
|
-e "s|{{ID}}|${esc_id}|g" \
|
|
"$PROMPT_FILE" > "$resolved_prompt"
|
|
|
|
# Inject user-layer personalization into the temporary worker prompt.
|
|
# The resolved prompt is gitignored runtime state, so user profile data stays
|
|
# out of the system layer while batch scoring matches interactive scoring.
|
|
for context_file in "$PROJECT_DIR/modes/_profile.md" "$PROJECT_DIR/config/profile.yml" "$PROJECT_DIR/modes/_custom.md"; do
|
|
if [[ -f "$context_file" ]]; then
|
|
{
|
|
printf '\n\n---\n\n'
|
|
printf '## Runtime personalization: %s\n\n' "${context_file#"$PROJECT_DIR/"}"
|
|
sed 's/^/ /' "$context_file"
|
|
printf '\n'
|
|
} >> "$resolved_prompt"
|
|
fi
|
|
done
|
|
|
|
# Launch claude -p worker.
|
|
# The model is resolved once per run from spend_tier unless --model was
|
|
# passed. Building the command in an array keeps quoting safe regardless.
|
|
# --strict-mcp-config (with no --mcp-config) starts workers with no MCP
|
|
# servers: they only evaluate offers and need none. Without it each parallel
|
|
# worker inherits the parent session's MCP (e.g. Playwright) and they deadlock
|
|
# fighting over the single shared browser when --parallel > 1 (issue #506).
|
|
local -a claude_args=(-p --dangerously-skip-permissions --strict-mcp-config)
|
|
if [[ -n "$RESOLVED_MODEL" ]]; then
|
|
claude_args+=(--model "$RESOLVED_MODEL")
|
|
fi
|
|
claude_args+=(--append-system-prompt-file "$resolved_prompt" "$prompt")
|
|
|
|
local exit_code=0
|
|
local terminal_failure_recorded=false
|
|
local shim_retries=0
|
|
local max_shim_retries=4
|
|
while true; do
|
|
exit_code=0
|
|
claude "${claude_args[@]}" > "$log_file" 2>&1 || exit_code=$?
|
|
|
|
if [[ $exit_code -eq 0 ]]; then
|
|
break
|
|
fi
|
|
|
|
# Check for Claude Code npm shim swap (exit code 127 + command not found)
|
|
if [[ $exit_code -eq 127 ]] && grep -qE "(claude: command not found|claude:.*not found|cannot find.*claude)" "$log_file" && (( shim_retries < max_shim_retries )); then
|
|
shim_retries=$((shim_retries + 1))
|
|
echo " ⏳ Claude command not found (shim swap detected). Retrying in 30s (attempt $shim_retries/$max_shim_retries)..."
|
|
sleep 30
|
|
continue
|
|
fi
|
|
|
|
if is_session_limit_log "$log_file"; then
|
|
mark_paused_rate_limit "$id" "$url" "$started_at" "$report_num" "$retries" "$log_file"
|
|
echo " ⏸️ Session/rate limit reached; pausing batch without consuming retry budget."
|
|
terminal_failure_recorded=true
|
|
break
|
|
fi
|
|
|
|
if is_rate_limit_log "$log_file" && (( retries < MAX_RETRIES )); then
|
|
if (( RATE_LIMIT_SLEEP <= 0 )); then
|
|
mark_paused_rate_limit "$id" "$url" "$started_at" "$report_num" "$retries" "$log_file"
|
|
echo " ⏸️ Rate limited and --rate-limit-sleep is 0; pausing batch without consuming retry budget."
|
|
terminal_failure_recorded=true
|
|
break
|
|
fi
|
|
retries=$((retries + 1))
|
|
local retry_completed_at
|
|
retry_completed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
update_state "$id" "$url" "rate_limited" "$started_at" "$retry_completed_at" "$report_num" "-" "rate-limit; retrying after ${RATE_LIMIT_SLEEP}s" "$retries"
|
|
echo " ⏳ Rate limited (attempt $retries/$MAX_RETRIES). Waiting ${RATE_LIMIT_SLEEP}s before retry..."
|
|
sleep "$RATE_LIMIT_SLEEP"
|
|
continue
|
|
fi
|
|
|
|
break
|
|
done
|
|
|
|
# Cleanup resolved prompt
|
|
rm -f "$resolved_prompt"
|
|
|
|
local completed_at
|
|
completed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
|
|
if [[ $exit_code -eq 0 ]]; then
|
|
# Try to extract score from worker output
|
|
local score="-"
|
|
local score_match
|
|
score_match=$(sed -nE 's/.*"score":[[:space:]]*([0-9.]+).*/\1/p' "$log_file" 2>/dev/null | head -1 || true)
|
|
if [[ -n "$score_match" ]]; then
|
|
score="$score_match"
|
|
fi
|
|
|
|
# Check min-score gate
|
|
if is_decimal_number "$score" && awk -v min="$MIN_SCORE" 'BEGIN{exit !(min > 0)}'; then
|
|
if awk -v score="$score" -v min="$MIN_SCORE" 'BEGIN{exit !(score < min)}'; then
|
|
update_state "$id" "$url" "skipped" "$started_at" "$completed_at" "$report_num" "$score" "below-min-score" "$retries"
|
|
echo " ⏭️ Skipped (score: $score < min-score: $MIN_SCORE)"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
update_state "$id" "$url" "completed" "$started_at" "$completed_at" "$report_num" "$score" "-" "$retries"
|
|
echo " ✅ Completed (score: $score, report: $report_num)"
|
|
elif [[ "$terminal_failure_recorded" == "false" ]]; then
|
|
if (( retries < MAX_RETRIES )); then
|
|
retries=$((retries + 1))
|
|
fi
|
|
local error_msg
|
|
error_msg=$(tail -5 "$log_file" 2>/dev/null | tr '\n' ' ' | cut -c1-200 || echo "Unknown error (exit code $exit_code)")
|
|
update_state "$id" "$url" "failed" "$started_at" "$completed_at" "$report_num" "-" "$error_msg" "$retries"
|
|
echo " ❌ Failed (attempt $retries, exit code $exit_code)"
|
|
fi
|
|
}
|
|
|
|
# Merge tracker additions into applications.md
|
|
merge_tracker() {
|
|
echo ""
|
|
echo "=== Merging tracker additions ==="
|
|
node "$PROJECT_DIR/merge-tracker.mjs"
|
|
echo ""
|
|
echo "=== Reconciling pipeline.md ==="
|
|
node "$PROJECT_DIR/reconcile-pipeline.mjs" || echo "⚠️ Pipeline reconcile had issues (see above)"
|
|
echo ""
|
|
echo "=== Verifying pipeline integrity ==="
|
|
node "$PROJECT_DIR/verify-pipeline.mjs" || echo "⚠️ Verification found issues (see above)"
|
|
}
|
|
|
|
# Print summary
|
|
print_summary() {
|
|
echo ""
|
|
echo "=== Batch Summary ==="
|
|
|
|
if [[ ! -f "$STATE_FILE" ]]; then
|
|
echo "No state file found."
|
|
return
|
|
fi
|
|
|
|
local total=0 completed=0 skipped=0 failed=0 pending=0
|
|
local score_sum=0 score_count=0
|
|
|
|
while IFS=$'\t' read -r sid _ sstatus _ _ _ sscore _ _; do
|
|
[[ "$sid" == "id" ]] && continue
|
|
total=$((total + 1))
|
|
case "$sstatus" in
|
|
completed) completed=$((completed + 1))
|
|
if is_decimal_number "$sscore"; then
|
|
score_sum=$(awk -v sum="$score_sum" -v score="$sscore" 'BEGIN{print sum + score}' 2>/dev/null || echo "$score_sum")
|
|
score_count=$((score_count + 1))
|
|
fi
|
|
;;
|
|
skipped) skipped=$((skipped + 1)) ;;
|
|
failed) failed=$((failed + 1)) ;;
|
|
*) pending=$((pending + 1)) ;;
|
|
esac
|
|
done < "$STATE_FILE"
|
|
|
|
echo "Total: $total | Completed: $completed | Skipped: $skipped | Failed: $failed | Pending: $pending"
|
|
|
|
if (( score_count > 0 )); then
|
|
local avg
|
|
avg=$(awk -v sum="$score_sum" -v count="$score_count" 'BEGIN{printf "%.1f", sum / count}' 2>/dev/null || echo "N/A")
|
|
echo "Average score: $avg/5 ($score_count scored)"
|
|
fi
|
|
}
|
|
|
|
print_status_table() {
|
|
if [[ ! -f "$STATE_FILE" ]]; then
|
|
echo "No state file found at $STATE_FILE"
|
|
return
|
|
fi
|
|
|
|
local total=0 completed=0 processing=0 failed=0 pending=0 skipped=0 rate_limited=0 paused_rate_limit=0
|
|
local score_sum=0 score_count=0
|
|
|
|
# Read first line to skip header
|
|
local header=true
|
|
while IFS=$'\t' read -r sid surl sstatus sstarted scompleted sreport sscore serror sretries || [[ -n "$sid" ]]; do
|
|
if [[ "$header" == "true" ]]; then
|
|
header=false
|
|
continue
|
|
fi
|
|
[[ -z "$sid" ]] && continue
|
|
sstatus="${sstatus%$'\r'}"
|
|
sscore="${sscore%$'\r'}"
|
|
serror="${serror%$'\r'}"
|
|
sreport="${sreport%$'\r'}"
|
|
total=$((total + 1))
|
|
case "$sstatus" in
|
|
completed)
|
|
completed=$((completed + 1))
|
|
if is_decimal_number "$sscore"; then
|
|
score_sum=$(awk -v sum="$score_sum" -v score="$sscore" 'BEGIN{print sum + score}' 2>/dev/null || echo "$score_sum")
|
|
score_count=$((score_count + 1))
|
|
fi
|
|
;;
|
|
processing) processing=$((processing + 1)) ;;
|
|
failed) failed=$((failed + 1)) ;;
|
|
skipped) skipped=$((skipped + 1)) ;;
|
|
rate_limited) rate_limited=$((rate_limited + 1)) ;;
|
|
paused_rate_limit) paused_rate_limit=$((paused_rate_limit + 1)) ;;
|
|
*) pending=$((pending + 1)) ;;
|
|
esac
|
|
done < "$STATE_FILE"
|
|
|
|
echo "=== Batch Progress ==="
|
|
echo "Total: $total | Completed: $completed | Processing: $processing | Failed: $failed | Pending: $pending | Skipped: $skipped | Rate Limited: $rate_limited | Paused: $paused_rate_limit"
|
|
if (( score_count > 0 )); then
|
|
local avg
|
|
avg=$(awk -v sum="$score_sum" -v count="$score_count" 'BEGIN{printf "%.1f", sum / count}' 2>/dev/null || echo "N/A")
|
|
echo "Average score: $avg/5 ($score_count scored)"
|
|
fi
|
|
echo ""
|
|
|
|
# Format the per-job table:
|
|
# Columns: ID, Status, Report, Score, Target (URL or Error Message)
|
|
printf "%-4s | %-17s | %-6s | %-5s | %-40s\n" "ID" "Status" "Report" "Score" "URL / Error"
|
|
printf "%-4s+%-19s+%-8s+%-7s+%-42s\n" "----" "-------------------" "--------" "-------" "------------------------------------------"
|
|
|
|
header=true
|
|
while IFS=$'\t' read -r sid surl sstatus sstarted scompleted sreport sscore serror sretries || [[ -n "$sid" ]]; do
|
|
if [[ "$header" == "true" ]]; then
|
|
header=false
|
|
continue
|
|
fi
|
|
[[ -z "$sid" ]] && continue
|
|
sstatus="${sstatus%$'\r'}"
|
|
sscore="${sscore%$'\r'}"
|
|
serror="${serror%$'\r'}"
|
|
sreport="${sreport%$'\r'}"
|
|
local target="$surl"
|
|
if [[ "$sstatus" == "failed" && -n "$serror" && "$serror" != "-" ]]; then
|
|
target="Error: $serror"
|
|
fi
|
|
# Trim target to fit nicely (e.g. 50 chars)
|
|
if (( ${#target} > 50 )); then
|
|
target="${target:0:47}..."
|
|
fi
|
|
printf "%-4s | %-17s | %-6s | %-5s | %-50s\n" "$sid" "$sstatus" "$sreport" "$sscore" "$target"
|
|
done < "$STATE_FILE"
|
|
}
|
|
|
|
watch_status() {
|
|
local active_pid=""
|
|
if [[ -f "$LOCK_FILE" ]]; then
|
|
active_pid=$(cat "$LOCK_FILE" 2>/dev/null || true)
|
|
fi
|
|
|
|
if [[ -n "$active_pid" ]] && kill -0 "$active_pid" 2>/dev/null; then
|
|
echo "Watching batch-runner (PID $active_pid)... Press Ctrl+C to stop."
|
|
while kill -0 "$active_pid" 2>/dev/null; do
|
|
clear || printf "\033[c"
|
|
echo "=== Watching Batch Progress (PID $active_pid) ==="
|
|
print_status_table
|
|
sleep 2
|
|
done
|
|
echo ""
|
|
echo "=== Batch runner process (PID $active_pid) has finished ==="
|
|
else
|
|
echo "No active batch-runner detected."
|
|
fi
|
|
|
|
echo "Showing final status:"
|
|
print_status_table
|
|
|
|
# Chain verify-pipeline.mjs
|
|
if [[ -f "$PROJECT_DIR/verify-pipeline.mjs" ]]; then
|
|
echo ""
|
|
echo "=== Running pipeline verification ==="
|
|
node "$PROJECT_DIR/verify-pipeline.mjs" || echo "⚠️ Verification found issues"
|
|
fi
|
|
}
|
|
|
|
# Main
|
|
main() {
|
|
if [[ "$STATUS_ONLY" == "true" ]]; then
|
|
check_status_prerequisites
|
|
print_status_table
|
|
exit 0
|
|
fi
|
|
|
|
if [[ "$WATCH_MODE" == "true" ]]; then
|
|
check_status_prerequisites
|
|
watch_status
|
|
exit 0
|
|
fi
|
|
|
|
check_prerequisites
|
|
|
|
resolve_worker_model
|
|
|
|
if [[ "$DRY_RUN" == "false" ]]; then
|
|
acquire_lock
|
|
rm -f "$PAUSE_FILE"
|
|
fi
|
|
|
|
init_state
|
|
|
|
# Count input offers (skip header, ignore blank lines)
|
|
local total_input
|
|
total_input=$(tail -n +2 "$INPUT_FILE" | grep -c '[^[:space:]]' 2>/dev/null || true)
|
|
total_input="${total_input:-0}"
|
|
|
|
if (( total_input == 0 )); then
|
|
echo "No offers in $INPUT_FILE. Add offers first."
|
|
exit 0
|
|
fi
|
|
|
|
echo "=== career-ops batch runner ==="
|
|
if (( LIMIT > 0 )); then
|
|
echo "Parallel: $PARALLEL | Max retries: $MAX_RETRIES | Limit: $LIMIT"
|
|
else
|
|
echo "Parallel: $PARALLEL | Max retries: $MAX_RETRIES"
|
|
fi
|
|
if [[ "$RESOLVED_SPEND_TIER" == "override" ]]; then
|
|
echo "Model: $RESOLVED_MODEL (explicit --model override)"
|
|
else
|
|
echo "Model: $RESOLVED_MODEL (spend_tier=${RESOLVED_SPEND_TIER})"
|
|
fi
|
|
echo "Input: $total_input offers"
|
|
echo ""
|
|
|
|
# Build list of offers to process
|
|
local -a pending_ids=()
|
|
local -a pending_urls=()
|
|
local -a pending_sources=()
|
|
local -a pending_notes=()
|
|
|
|
while IFS=$'\t' read -r id url source notes; do
|
|
[[ "$id" == "id" ]] && continue # skip header
|
|
[[ -z "$id" || -z "$url" ]] && continue
|
|
|
|
# Guard against non-numeric id values
|
|
[[ "$id" =~ ^[0-9]+$ ]] || continue
|
|
|
|
# Skip if before start-from
|
|
if (( id < START_FROM )); then
|
|
continue
|
|
fi
|
|
|
|
local status
|
|
status=$(get_status "$id")
|
|
|
|
if [[ "$RESUME_PAUSED" == "true" ]]; then
|
|
if [[ "$status" != "paused_rate_limit" ]]; then
|
|
continue
|
|
fi
|
|
elif [[ "$RETRY_FAILED" == "true" ]]; then
|
|
# Only process failed offers
|
|
if [[ "$status" != "failed" ]]; then
|
|
continue
|
|
fi
|
|
# Check retry limit
|
|
local retries
|
|
retries=$(get_retries "$id")
|
|
if (( retries >= MAX_RETRIES )); then
|
|
echo "SKIP #$id: max retries ($MAX_RETRIES) reached"
|
|
continue
|
|
fi
|
|
else
|
|
# Skip terminal offers
|
|
if [[ "$status" == "completed" || "$status" == "skipped" ]]; then
|
|
continue
|
|
fi
|
|
# Paused rate-limit offers resume explicitly with --resume-paused.
|
|
if [[ "$status" == "paused_rate_limit" ]]; then
|
|
continue
|
|
fi
|
|
# Skip failed offers that hit retry limit (unless --retry-failed)
|
|
if [[ "$status" == "failed" ]]; then
|
|
local retries
|
|
retries=$(get_retries "$id")
|
|
if (( retries >= MAX_RETRIES )); then
|
|
echo "SKIP #$id: failed and max retries reached (use --retry-failed to force)"
|
|
continue
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if (( LIMIT > 0 )) && (( ${#pending_ids[@]} >= LIMIT )); then
|
|
break
|
|
fi
|
|
|
|
pending_ids+=("$id")
|
|
pending_urls+=("$url")
|
|
pending_sources+=("$source")
|
|
pending_notes+=("$notes")
|
|
done < "$INPUT_FILE"
|
|
|
|
local pending_count=${#pending_ids[@]}
|
|
|
|
if (( pending_count == 0 )); then
|
|
echo "No offers to process."
|
|
print_summary
|
|
exit 0
|
|
fi
|
|
|
|
echo "Pending: $pending_count offers"
|
|
echo ""
|
|
|
|
# Dry run: just list
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
echo "=== DRY RUN (no processing) ==="
|
|
for i in "${!pending_ids[@]}"; do
|
|
local status
|
|
status=$(get_status "${pending_ids[$i]}")
|
|
echo " #${pending_ids[$i]}: ${pending_urls[$i]} [${pending_sources[$i]}] (status: $status)"
|
|
done
|
|
echo ""
|
|
echo "Would process $pending_count offers"
|
|
exit 0
|
|
fi
|
|
|
|
# Process offers
|
|
if (( PARALLEL <= 1 )); then
|
|
# Sequential processing
|
|
for i in "${!pending_ids[@]}"; do
|
|
process_offer "${pending_ids[$i]}" "${pending_urls[$i]}" "${pending_sources[$i]}" "${pending_notes[$i]}"
|
|
if [[ "$BATCH_PAUSED" == "true" || -f "$PAUSE_FILE" ]]; then
|
|
echo "=== Batch paused: session/rate limit reached. Resume later with --resume-paused. ==="
|
|
break
|
|
fi
|
|
done
|
|
else
|
|
# Parallel processing with job control
|
|
local running=0
|
|
local -a pids=()
|
|
local -a pid_ids=()
|
|
|
|
for i in "${!pending_ids[@]}"; do
|
|
if [[ "$BATCH_PAUSED" == "true" || -f "$PAUSE_FILE" ]]; then
|
|
echo "=== Batch paused: session/rate limit reached. Waiting for running workers, not scheduling new offers. ==="
|
|
break
|
|
fi
|
|
|
|
# Wait if we're at parallel limit
|
|
while (( running >= PARALLEL )); do
|
|
# Wait for any child to finish
|
|
for j in "${!pids[@]}"; do
|
|
if ! kill -0 "${pids[$j]}" 2>/dev/null; then
|
|
wait "${pids[$j]}" 2>/dev/null || true
|
|
unset 'pids[j]'
|
|
unset 'pid_ids[j]'
|
|
running=$((running - 1))
|
|
fi
|
|
done
|
|
# Compact arrays
|
|
pids=("${pids[@]}")
|
|
pid_ids=("${pid_ids[@]}")
|
|
if [[ "$BATCH_PAUSED" == "true" || -f "$PAUSE_FILE" ]]; then
|
|
echo "=== Batch paused: session/rate limit reached. Waiting for running workers, not scheduling new offers. ==="
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
if [[ "$BATCH_PAUSED" == "true" || -f "$PAUSE_FILE" ]]; then
|
|
break
|
|
fi
|
|
|
|
# Launch worker in background
|
|
process_offer "${pending_ids[$i]}" "${pending_urls[$i]}" "${pending_sources[$i]}" "${pending_notes[$i]}" &
|
|
pids+=($!)
|
|
pid_ids+=("${pending_ids[$i]}")
|
|
running=$((running + 1))
|
|
done
|
|
|
|
# Wait for remaining workers
|
|
for pid in "${pids[@]}"; do
|
|
wait "$pid" 2>/dev/null || true
|
|
done
|
|
fi
|
|
|
|
# Merge tracker additions
|
|
merge_tracker
|
|
|
|
# Print summary
|
|
print_summary
|
|
|
|
exit 0
|
|
}
|
|
|
|
main "$@"
|