Skip to content

Commit 5a77d86

Browse files
committed
fix: harden Ralph session state recovery
1 parent e64d010 commit 5a77d86

13 files changed

Lines changed: 575 additions & 88 deletions

ralph/RALPH-REFERENCE.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ This applies to every driver that exposes resumable IDs today:
9797

9898
| File | Purpose |
9999
|------|---------|
100-
| `.ralph/.ralph_session` | Current session ID and timestamps |
100+
| `.ralph/.ralph_session` | Current Ralph session state (active or reset/inactive) |
101101
| `.ralph/.ralph_session_history` | History of last 50 session transitions |
102102
| `.ralph/.claude_session_id` | Persisted driver session ID (shared filename for historical reasons; used by Claude Code, Codex, and Cursor) |
103103

@@ -119,13 +119,23 @@ Sessions expire after 24 hours (configurable via `SESSION_EXPIRY_HOURS` in `.ral
119119

120120
### Session State Structure
121121

122+
Active session payload:
123+
122124
```json
123125
{
124126
"session_id": "uuid-string",
125127
"created_at": "ISO-timestamp",
126-
"last_used": "ISO-timestamp",
127-
"reset_at": "ISO-timestamp (if reset)",
128-
"reset_reason": "reason string (if reset)"
128+
"last_used": "ISO-timestamp"
129+
}
130+
```
131+
132+
Reset/inactive payload:
133+
134+
```json
135+
{
136+
"session_id": "",
137+
"reset_at": "ISO-timestamp",
138+
"reset_reason": "reason string"
129139
}
130140
```
131141

ralph/lib/date_utils.sh

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,35 +49,37 @@ get_epoch_seconds() {
4949
# Convert ISO 8601 timestamp to Unix epoch seconds
5050
# Input: ISO timestamp (e.g., "2025-01-15T10:30:00+00:00")
5151
# Returns: Unix epoch seconds on stdout
52-
# Falls back to current epoch on parse failure (safe default)
53-
parse_iso_to_epoch() {
52+
# Returns non-zero on parse failure.
53+
parse_iso_to_epoch_strict() {
5454
local iso_timestamp=$1
5555

5656
if [[ -z "$iso_timestamp" || "$iso_timestamp" == "null" ]]; then
57-
date +%s
58-
return
57+
return 1
5958
fi
6059

60+
local normalized_iso
61+
normalized_iso=$(printf '%s' "$iso_timestamp" | sed -E 's/\.([0-9]+)(Z|[+-][0-9]{2}:[0-9]{2})$/\2/')
62+
6163
# Try GNU date -d (Linux, macOS with Homebrew coreutils)
6264
local result
6365
if result=$(date -d "$iso_timestamp" +%s 2>/dev/null) && [[ "$result" =~ ^[0-9]+$ ]]; then
6466
echo "$result"
65-
return
67+
return 0
6668
fi
6769

6870
# Try BSD date -j (native macOS)
6971
# Normalize timezone for BSD parsing (Z → +0000, ±HH:MM → ±HHMM)
7072
local tz_fixed
71-
tz_fixed=$(echo "$iso_timestamp" | sed -E 's/Z$/+0000/; s/([+-][0-9]{2}):([0-9]{2})$/\1\2/')
73+
tz_fixed=$(printf '%s' "$normalized_iso" | sed -E 's/Z$/+0000/; s/([+-][0-9]{2}):([0-9]{2})$/\1\2/')
7274
if result=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "$tz_fixed" +%s 2>/dev/null) && [[ "$result" =~ ^[0-9]+$ ]]; then
7375
echo "$result"
74-
return
76+
return 0
7577
fi
7678

7779
# Fallback: manual epoch arithmetic from ISO components
7880
# Parse: YYYY-MM-DDTHH:MM:SS (ignore timezone, assume UTC)
7981
local year month day hour minute second
80-
if [[ "$iso_timestamp" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2}) ]]; then
82+
if [[ "$normalized_iso" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2}) ]]; then
8183
year="${BASH_REMATCH[1]}"
8284
month="${BASH_REMATCH[2]}"
8385
day="${BASH_REMATCH[3]}"
@@ -88,10 +90,26 @@ parse_iso_to_epoch() {
8890
# Use date with explicit components if available
8991
if result=$(date -u -d "${year}-${month}-${day} ${hour}:${minute}:${second}" +%s 2>/dev/null) && [[ "$result" =~ ^[0-9]+$ ]]; then
9092
echo "$result"
91-
return
93+
return 0
9294
fi
9395
fi
9496

97+
return 1
98+
}
99+
100+
# Convert ISO 8601 timestamp to Unix epoch seconds
101+
# Input: ISO timestamp (e.g., "2025-01-15T10:30:00+00:00")
102+
# Returns: Unix epoch seconds on stdout
103+
# Falls back to current epoch on parse failure (safe default)
104+
parse_iso_to_epoch() {
105+
local iso_timestamp=$1
106+
local result
107+
108+
if result=$(parse_iso_to_epoch_strict "$iso_timestamp"); then
109+
echo "$result"
110+
return 0
111+
fi
112+
95113
# Ultimate fallback: return current epoch (safe default)
96114
date +%s
97115
}
@@ -101,4 +119,5 @@ export -f get_iso_timestamp
101119
export -f get_next_hour_time
102120
export -f get_basic_timestamp
103121
export -f get_epoch_seconds
122+
export -f parse_iso_to_epoch_strict
104123
export -f parse_iso_to_epoch

ralph/ralph_loop.sh

Lines changed: 173 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,7 @@ save_claude_session() {
10341034
session_id=$(extract_session_id_from_output "$output_file" 2>/dev/null || echo "")
10351035
if [[ -n "$session_id" && "$session_id" != "null" ]]; then
10361036
echo "$session_id" > "$CLAUDE_SESSION_FILE"
1037+
sync_ralph_session_with_driver "$session_id"
10371038
log_status "INFO" "Saved session: ${session_id:0:20}..."
10381039
fi
10391040
fi
@@ -1043,6 +1044,101 @@ save_claude_session() {
10431044
# SESSION LIFECYCLE MANAGEMENT FUNCTIONS (Phase 1.2)
10441045
# =============================================================================
10451046

1047+
write_active_ralph_session() {
1048+
local session_id=$1
1049+
local created_at=$2
1050+
local last_used=${3:-$created_at}
1051+
1052+
jq -n \
1053+
--arg session_id "$session_id" \
1054+
--arg created_at "$created_at" \
1055+
--arg last_used "$last_used" \
1056+
'{
1057+
session_id: $session_id,
1058+
created_at: $created_at,
1059+
last_used: $last_used
1060+
}' > "$RALPH_SESSION_FILE"
1061+
}
1062+
1063+
write_inactive_ralph_session() {
1064+
local reset_at=$1
1065+
local reset_reason=$2
1066+
1067+
jq -n \
1068+
--arg session_id "" \
1069+
--arg reset_at "$reset_at" \
1070+
--arg reset_reason "$reset_reason" \
1071+
'{
1072+
session_id: $session_id,
1073+
reset_at: $reset_at,
1074+
reset_reason: $reset_reason
1075+
}' > "$RALPH_SESSION_FILE"
1076+
}
1077+
1078+
get_ralph_session_state() {
1079+
if [[ ! -f "$RALPH_SESSION_FILE" ]]; then
1080+
echo "missing"
1081+
return 0
1082+
fi
1083+
1084+
if ! jq empty "$RALPH_SESSION_FILE" 2>/dev/null; then
1085+
echo "invalid"
1086+
return 0
1087+
fi
1088+
1089+
local session_id_type
1090+
session_id_type=$(
1091+
jq -r 'if has("session_id") then (.session_id | type) else "missing" end' \
1092+
"$RALPH_SESSION_FILE" 2>/dev/null
1093+
) || {
1094+
echo "invalid"
1095+
return 0
1096+
}
1097+
1098+
if [[ "$session_id_type" != "string" ]]; then
1099+
echo "invalid"
1100+
return 0
1101+
fi
1102+
1103+
local session_id
1104+
session_id=$(jq -r '.session_id' "$RALPH_SESSION_FILE" 2>/dev/null) || {
1105+
echo "invalid"
1106+
return 0
1107+
}
1108+
1109+
if [[ "$session_id" == "" ]]; then
1110+
echo "inactive"
1111+
return 0
1112+
fi
1113+
1114+
local created_at_type
1115+
created_at_type=$(
1116+
jq -r 'if has("created_at") then (.created_at | type) else "missing" end' \
1117+
"$RALPH_SESSION_FILE" 2>/dev/null
1118+
) || {
1119+
echo "invalid"
1120+
return 0
1121+
}
1122+
1123+
if [[ "$created_at_type" != "string" ]]; then
1124+
echo "invalid"
1125+
return 0
1126+
fi
1127+
1128+
local created_at
1129+
created_at=$(jq -r '.created_at' "$RALPH_SESSION_FILE" 2>/dev/null) || {
1130+
echo "invalid"
1131+
return 0
1132+
}
1133+
1134+
if ! is_usable_ralph_session_created_at "$created_at"; then
1135+
echo "invalid"
1136+
return 0
1137+
fi
1138+
1139+
echo "active"
1140+
}
1141+
10461142
# Get current session ID from Ralph session file
10471143
# Returns: session ID string or empty if not found
10481144
get_session_id() {
@@ -1064,6 +1160,65 @@ get_session_id() {
10641160
return 0
10651161
}
10661162

1163+
is_usable_ralph_session_created_at() {
1164+
local created_at=$1
1165+
if [[ -z "$created_at" || "$created_at" == "null" ]]; then
1166+
return 1
1167+
fi
1168+
1169+
local created_at_epoch
1170+
created_at_epoch=$(parse_iso_to_epoch_strict "$created_at") || return 1
1171+
1172+
local now_epoch
1173+
now_epoch=$(get_epoch_seconds)
1174+
1175+
[[ "$created_at_epoch" -le "$now_epoch" ]]
1176+
}
1177+
1178+
get_active_session_created_at() {
1179+
if [[ "$(get_ralph_session_state)" != "active" ]]; then
1180+
echo ""
1181+
return 0
1182+
fi
1183+
1184+
local created_at
1185+
created_at=$(jq -r '.created_at // ""' "$RALPH_SESSION_FILE" 2>/dev/null)
1186+
if [[ "$created_at" == "null" ]]; then
1187+
created_at=""
1188+
fi
1189+
1190+
if ! is_usable_ralph_session_created_at "$created_at"; then
1191+
echo ""
1192+
return 0
1193+
fi
1194+
1195+
echo "$created_at"
1196+
}
1197+
1198+
sync_ralph_session_with_driver() {
1199+
local driver_session_id=$1
1200+
if [[ -z "$driver_session_id" || "$driver_session_id" == "null" ]]; then
1201+
return 0
1202+
fi
1203+
1204+
local ts
1205+
ts=$(get_iso_timestamp)
1206+
1207+
if [[ "$(get_ralph_session_state)" == "active" ]]; then
1208+
local current_session_id
1209+
current_session_id=$(get_session_id)
1210+
local current_created_at
1211+
current_created_at=$(get_active_session_created_at)
1212+
1213+
if [[ "$current_session_id" == "$driver_session_id" && -n "$current_created_at" ]]; then
1214+
write_active_ralph_session "$driver_session_id" "$current_created_at" "$ts"
1215+
return 0
1216+
fi
1217+
fi
1218+
1219+
write_active_ralph_session "$driver_session_id" "$ts" "$ts"
1220+
}
1221+
10671222
# Reset session with reason logging
10681223
# Usage: reset_session "reason_for_reset"
10691224
reset_session() {
@@ -1073,20 +1228,7 @@ reset_session() {
10731228
local reset_timestamp
10741229
reset_timestamp=$(get_iso_timestamp)
10751230

1076-
# Always create/overwrite the session file using jq for safe JSON escaping
1077-
jq -n \
1078-
--arg session_id "" \
1079-
--arg created_at "" \
1080-
--arg last_used "" \
1081-
--arg reset_at "$reset_timestamp" \
1082-
--arg reset_reason "$reason" \
1083-
'{
1084-
session_id: $session_id,
1085-
created_at: $created_at,
1086-
last_used: $last_used,
1087-
reset_at: $reset_at,
1088-
reset_reason: $reset_reason
1089-
}' > "$RALPH_SESSION_FILE"
1231+
write_inactive_ralph_session "$reset_timestamp" "$reason"
10901232

10911233
# Also clear the Claude session file for consistency
10921234
rm -f "$CLAUDE_SESSION_FILE" 2>/dev/null
@@ -1175,67 +1317,39 @@ init_session_tracking() {
11751317
local ts
11761318
ts=$(get_iso_timestamp)
11771319

1178-
# Create session file if it doesn't exist
1179-
if [[ ! -f "$RALPH_SESSION_FILE" ]]; then
1180-
local new_session_id
1181-
new_session_id=$(generate_session_id)
1182-
1183-
jq -n \
1184-
--arg session_id "$new_session_id" \
1185-
--arg created_at "$ts" \
1186-
--arg last_used "$ts" \
1187-
--arg reset_at "" \
1188-
--arg reset_reason "" \
1189-
'{
1190-
session_id: $session_id,
1191-
created_at: $created_at,
1192-
last_used: $last_used,
1193-
reset_at: $reset_at,
1194-
reset_reason: $reset_reason
1195-
}' > "$RALPH_SESSION_FILE"
1196-
1197-
log_status "INFO" "Initialized session tracking (session: $new_session_id)"
1320+
local session_state
1321+
session_state=$(get_ralph_session_state)
1322+
if [[ "$session_state" == "active" ]]; then
11981323
return 0
11991324
fi
12001325

1201-
# Validate existing session file
1202-
if ! jq empty "$RALPH_SESSION_FILE" 2>/dev/null; then
1326+
if [[ "$session_state" == "invalid" ]]; then
12031327
log_status "WARN" "Corrupted session file detected, recreating..."
1204-
local new_session_id
1205-
new_session_id=$(generate_session_id)
1206-
1207-
jq -n \
1208-
--arg session_id "$new_session_id" \
1209-
--arg created_at "$ts" \
1210-
--arg last_used "$ts" \
1211-
--arg reset_at "$ts" \
1212-
--arg reset_reason "corrupted_file_recovery" \
1213-
'{
1214-
session_id: $session_id,
1215-
created_at: $created_at,
1216-
last_used: $last_used,
1217-
reset_at: $reset_at,
1218-
reset_reason: $reset_reason
1219-
}' > "$RALPH_SESSION_FILE"
12201328
fi
1329+
1330+
local new_session_id
1331+
new_session_id=$(generate_session_id)
1332+
write_active_ralph_session "$new_session_id" "$ts" "$ts"
1333+
1334+
log_status "INFO" "Initialized session tracking (session: $new_session_id)"
12211335
}
12221336

12231337
# Update last_used timestamp in session file (called on each loop iteration)
12241338
update_session_last_used() {
1225-
if [[ ! -f "$RALPH_SESSION_FILE" ]]; then
1339+
if [[ "$(get_ralph_session_state)" != "active" ]]; then
12261340
return 0
12271341
fi
12281342

12291343
local ts
12301344
ts=$(get_iso_timestamp)
12311345

1232-
# Update last_used in existing session file
1233-
local updated
1234-
updated=$(jq --arg last_used "$ts" '.last_used = $last_used' "$RALPH_SESSION_FILE" 2>/dev/null)
1235-
local jq_status=$?
1346+
local session_id
1347+
session_id=$(get_session_id)
1348+
local created_at
1349+
created_at=$(get_active_session_created_at)
12361350

1237-
if [[ $jq_status -eq 0 && -n "$updated" ]]; then
1238-
echo "$updated" > "$RALPH_SESSION_FILE"
1351+
if [[ -n "$session_id" && -n "$created_at" ]]; then
1352+
write_active_ralph_session "$session_id" "$created_at" "$ts"
12391353
fi
12401354
}
12411355

0 commit comments

Comments
 (0)