ruvnet-brain 4.7.18 → 4.7.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -81,7 +81,7 @@ PREV_HB=""
81
81
 
82
82
  # Start receipt. If the job vanishes without ever writing an end receipt, THIS is the evidence that
83
83
  # it started and never finished — a state the watchdog reports as FAILING, not as silence.
84
- write_receipt "{\"label\":\"$LABEL\",\"started_at\":\"$STARTED\",\"state\":\"running\",\"pid\":$$,\"run_id\":\"$RUN_ID\",\"command\":\"$(echo "$@" | sed 's/"/\\"/g')\"}"
84
+ write_receipt "{\"label\":\"$LABEL\",\"started_at\":\"$STARTED\",\"state\":\"running\",\"pid\":$$,\"run_id\":\"$RUN_ID\",\"command\":\"$(printf '%s %s' "$1" "${2:-}" | tr -d '"\\\n\r' | cut -c1-200)\"}"
85
85
 
86
86
  notify() { # notify <title> <body> <priority>
87
87
  # NTFY_TOPIC set-but-EMPTY is an explicit opt-out (2026-07-18): unit tests wrap this script around
@@ -92,8 +92,10 @@ notify() { # notify <title> <body> <priority>
92
92
  if [ "${NTFY_TOPIC+set}" = "set" ] && [ -z "$NTFY_TOPIC" ]; then return 0; fi
93
93
  topic="${NTFY_TOPIC:-}"
94
94
  [ -z "$topic" ] && [ -f "$HOME/.cache/ruvnet-brain/ntfy-topic" ] && topic="$(cat "$HOME/.cache/ruvnet-brain/ntfy-topic")"
95
- [ -z "$topic" ] && return 0
96
- curl -sS -m 10 -H "Title: $1" -H "Priority: $3" -H "Tags: rotating_light" -d "$2" "https://ntfy.sh/$topic" >/dev/null 2>&1 || true
95
+ [ -z "$topic" ] && return 1
96
+ # --fail: an HTTP error is a failed delivery. The caller records whether the page landed, so a page that
97
+ # never arrived is retried on the next run instead of muting the whole failing streak (review 2026-10-25).
98
+ curl -sS --fail -m 10 -H "Title: $1" -H "Priority: $3" -H "Tags: ${4:-rotating_light}" -d "$2" "https://ntfy.sh/$topic" >/dev/null 2>&1
97
99
  }
98
100
 
99
101
  # The run_id currently on disk for this label, or empty if the receipt is missing/unreadable/has
@@ -104,6 +106,17 @@ current_run_id() {
104
106
 
105
107
  finish() {
106
108
  code=${FORCED_CODE:-$?}
109
+ # Stop the run-time timer FIRST, on every exit path (skip, stale writer, normal), so it can never outlive
110
+ # this wrapper (Opus review 2026-10-25).
111
+ [ -n "${TIMER:-}" ] && kill "$TIMER" 2>/dev/null
112
+ timed_out=0
113
+ if [ -f "$HB.timeout-$RUN_ID" ]; then
114
+ # Anything the overrunning job left behind (started during its TERM handling, say) goes now.
115
+ timed_out=1; stop_job; rm -f "$HB.timeout-$RUN_ID"
116
+ fi
117
+ # An overrun is a failure however the job chose to exit on TERM, including 0 and 75 (the skip code), so this
118
+ # comes before the skip branch (GPT-6.1 review round 2).
119
+ [ "$timed_out" -eq 1 ] && [ "$code" -le 128 ] && code=143
107
120
  ended="$(ts)"
108
121
  dur=$(( $(date +%s) - START_EPOCH ))
109
122
  # Exit 75 = SKIP (lock held by a live run). Restore the pre-fire receipt so the live run's evidence
@@ -132,15 +145,36 @@ finish() {
132
145
  elif [ "$code" -gt 128 ]; then
133
146
  sig=$((code - 128))
134
147
  state="killed"; extra=",\"signal\":$sig"
148
+ [ "$timed_out" -eq 1 ] && extra="$extra,\"timed_out_after_sec\":$MAX_SECONDS"
135
149
  else
136
150
  state="failed"; extra=""
137
151
  fi
138
- write_receipt "{\"label\":\"$LABEL\",\"started_at\":\"$STARTED\",\"ended_at\":\"$ended\",\"state\":\"$state\",\"exit_code\":$code,\"duration_sec\":$dur,\"run_id\":\"$RUN_ID\"$extra}"
139
- # Gong on failure, immediately — not at the next watchdog sweep. A failing nightly should reach the
140
- # phone while it is still tonight's problem.
152
+ # PAGE ON THE TRANSITION, NOT ON EVERY RUN (owner, 2026-10-25: 10,747 notifications on one topic). A job that
153
+ # starts failing pages once, urgently, while it is still tonight's problem; while it KEEPS failing it stays
154
+ # quiet (failing_since carries the first failure; the nightly watchdog's digest still lists it); the run
155
+ # that passes again sends one low-priority "recovered" note. The previous run's state comes from PREV_HB.
156
+ prev_state="$(printf '%s' "$PREV_HB" | sed -n 's/.*"state":"\([^"]*\)".*/\1/p' | head -1)"
157
+ prev_since="$(printf '%s' "$PREV_HB" | sed -n 's/.*"failing_since":"\([^"]*\)".*/\1/p' | head -1)"
158
+ was_failing=0; case "$prev_state" in failed|killed) was_failing=1 ;; esac
159
+ # A failing streak counts as already paged only if that page was delivered ("paged":false = retry it).
160
+ # Only a page PROVEN delivered ("paged":true) keeps the streak quiet; a receipt from before this field existed
161
+ # counts as unknown and is paged once more (review round 2).
162
+ prev_unpaged=1; case "$PREV_HB" in *'"paged":true'*) prev_unpaged=0 ;; esac
163
+ since=""
141
164
  if [ "$code" -ne 0 ]; then
142
- notify "🔴 SCHEDULED JOB FAILED: $LABEL" "exit $code after ${dur}s — see the job's log. Receipt: $HB" "urgent"
165
+ if [ "$was_failing" -eq 1 ] && [ -n "$prev_since" ]; then since="$prev_since"; else since="$STARTED"; fi
166
+ extra="$extra,\"failing_since\":\"$since\""
143
167
  fi
168
+ if [ "$code" -ne 0 ] && { [ "$was_failing" -eq 0 ] || [ "$prev_unpaged" -eq 1 ]; }; then
169
+ reason="exit $code after ${dur}s"; [ "$timed_out" -eq 1 ] && reason="stopped after running past its ${MAX_SECONDS}s limit"
170
+ if notify "🔴 SCHEDULED JOB FAILED: $LABEL" "$reason — see the job's log. Receipt: $HB. Further failures stay quiet until it recovers." "urgent"; then
171
+ extra="$extra,\"paged\":true"; else extra="$extra,\"paged\":false"; fi
172
+ elif [ "$code" -ne 0 ]; then
173
+ extra="$extra,\"paged\":true"
174
+ elif [ "$was_failing" -eq 1 ]; then
175
+ notify "✅ Recovered: $LABEL" "passed again after failing since ${prev_since:-an earlier run}" "low" "white_check_mark" || true
176
+ fi
177
+ write_receipt "{\"label\":\"$LABEL\",\"started_at\":\"$STARTED\",\"ended_at\":\"$ended\",\"state\":\"$state\",\"exit_code\":$code,\"duration_sec\":$dur,\"run_id\":\"$RUN_ID\"$extra}"
144
178
  exit "$code"
145
179
  }
146
180
  trap finish EXIT
@@ -148,8 +182,46 @@ trap finish EXIT
148
182
  # The exit codes (143 = 128+15 TERM, 130 = 128+2 INT) intentionally reuse the same 128+signal
149
183
  # convention `wait` uses for a directly-signalled child, so finish() classifies BOTH as "killed"
150
184
  # with the right signal number, whether the signal reached the child directly or via this wrapper.
151
- trap 'FORCED_CODE=143; kill -TERM "$CHILD" 2>/dev/null; exit 143' TERM
152
- trap 'FORCED_CODE=130; kill -TERM "$CHILD" 2>/dev/null; exit 130' INT
185
+ # The job stays in this wrapper's process group, so launchd's own cleanup of a dead job (it kills the job's whole
186
+ # group) still covers everything, even if this wrapper is SIGKILLed (Opus review round 3: a separate group escaped it).
187
+ # job_pids: every process the job owns. Under launchd this wrapper leads its group, so that is every group member
188
+ # except this wrapper, its timer, and this wrapper's own helper processes (ps/awk/command substitutions) — which
189
+ # catches processes the job started late or that were reparented. Run any other way (a terminal, a test harness),
190
+ # it falls back to the job's current process tree.
191
+ OWN_GROUP=0; [ "$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')" = "$$" ] && OWN_GROUP=1
192
+ job_pids() {
193
+ if [ "$OWN_GROUP" = 1 ]; then
194
+ ps -A -o pid= -o ppid= -o pgid= 2>/dev/null | awk -v me=$$ -v t="${TIMER:-0}" -v c="${CHILD:-0}" '
195
+ { pid[NR]=$1; pp[NR]=$2; pg[NR]=$3 }
196
+ END { helper[t]=1
197
+ for (i=1;i<=NR;i++) if (pp[i]==me && pid[i]!=c) helper[pid[i]]=1
198
+ for (i=1;i<=NR;i++) if (pg[i]==me && pid[i]!=me && !(pid[i] in helper) && !(pp[i] in helper)) print pid[i] }'
199
+ else
200
+ tree_of "$CHILD"
201
+ fi
202
+ }
203
+ tree_of() { kill -0 "$1" 2>/dev/null && printf '%s ' "$1"
204
+ for k in $(ps -A -o pid= -o ppid= 2>/dev/null | awk -v p="$1" '$2==p {print $1}'); do tree_of "$k"; done; }
205
+ # stop_job: TERM everything the job owns, give it up to 10 s (inside launchd's 20 s exit allowance), then KILL.
206
+ # Every pid seen during the stop is remembered, so a descendant that outlives its parent (and so drops out of the
207
+ # process tree in the fallback mode) still gets the final KILL. The full guarantee, including processes started
208
+ # later, holds when this wrapper leads its group, as under launchd.
209
+ stop_job() {
210
+ seen="$(job_pids)"; [ -n "$seen" ] && kill -TERM $seen 2>/dev/null
211
+ i=0; while [ "$i" -lt 10 ]; do
212
+ now_pids="$(job_pids)"; seen="$seen $now_pids"
213
+ alive=""; for p in $seen; do kill -0 "$p" 2>/dev/null && alive="$alive $p"; done
214
+ [ -z "$alive" ] && return 0
215
+ sleep 1; i=$((i + 1))
216
+ done
217
+ for p in $seen $(job_pids); do kill -KILL "$p" 2>/dev/null; done
218
+ return 0
219
+ }
220
+ trap 'FORCED_CODE=143; [ -n "${TIMER:-}" ] && kill "$TIMER" 2>/dev/null; stop_job; exit 143' TERM
221
+ trap 'FORCED_CODE=130; [ -n "${TIMER:-}" ] && kill "$TIMER" 2>/dev/null; stop_job; exit 130' INT
222
+ # The timer never kills anything itself: it marks the overrun and signals this wrapper, which stops the job.
223
+ OVERRUN=0
224
+ trap 'OVERRUN=1' USR1
153
225
 
154
226
  # Run the job in the BACKGROUND and `wait` for it — do NOT run it in the foreground.
155
227
  # Break-test finding (2026-07-13): a POSIX shell blocked on a FOREGROUND child does not run its trap
@@ -160,4 +232,29 @@ trap 'FORCED_CODE=130; kill -TERM "$CHILD" 2>/dev/null; exit 130' INT
160
232
  # ("started and never finished"). Trap for catchable deaths, watchdog for uncatchable ones.
161
233
  "$@" &
162
234
  CHILD=$!
163
- wait "$CHILD"
235
+ # RUN-TIME LIMIT (2026-10-25: five jobs finished their work and then never exited, each holding its own
236
+ # next run off for 7-9 hours). After JOB_MAX_SECONDS (default 2 h; a plist may set less) the job is
237
+ # stopped: TERM, then KILL 10 s later. It is recorded as killed and pages once like any other failure.
238
+ MAX_SECONDS="${JOB_MAX_SECONDS:-7200}"
239
+ WRAPPER_PID=$$
240
+ case "$MAX_SECONDS" in ''|*[!0-9]*) MAX_SECONDS=7200 ;; esac
241
+ [ "$MAX_SECONDS" -lt 2 ] && MAX_SECONDS=2
242
+ (
243
+ # Short sleeps, not one long one: killing this shell then leaves at most a 2 s sleep behind, never a 2 h one
244
+ # holding the job's output open (review round 2).
245
+ # A wrapper that is gone (SIGKILLed: launchd then cleans the group itself) ends the timer at the next tick.
246
+ n=0; while [ "$n" -lt "$MAX_SECONDS" ]; do sleep 2; n=$((n + 2)); kill -0 "$WRAPPER_PID" 2>/dev/null || exit 0; done
247
+ # Only for OUR child: still alive and still parented by this wrapper. If the PID was reused, do nothing.
248
+ [ "$(ps -o ppid= -p "$CHILD" 2>/dev/null | tr -d ' ')" = "$WRAPPER_PID" ] || exit 0
249
+ : > "$HB.timeout-$RUN_ID"
250
+ kill -USR1 "$WRAPPER_PID" 2>/dev/null
251
+ ) </dev/null >/dev/null 2>&1 &
252
+ TIMER=$!
253
+ # `wait` returns early when USR1 arrives; stop the job, then wait again for its real exit status.
254
+ while :; do
255
+ if [ "$OVERRUN" = 1 ]; then OVERRUN=0; stop_job; fi
256
+ wait "$CHILD"; RC=$?
257
+ if [ "$OVERRUN" = 1 ]; then OVERRUN=0; stop_job; continue; fi
258
+ break
259
+ done
260
+ exit "$RC"
@@ -17,7 +17,10 @@
17
17
  set -eu
18
18
 
19
19
  cd "$(dirname "$0")/.."
20
- export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
20
+ # The owner's macOS-approved node (~/.local/share/ruvnet-brain/job-bin) and Apple's tools come first: this repo and
21
+ # the model cache live on an external volume, and launchd-started Homebrew node is held at its first read there
22
+ # (2026-10-25: every embedding shard "stalled" for 15 min, then the run failed after 1 h 49 min).
23
+ export PATH="$HOME/.local/share/ruvnet-brain/job-bin:/usr/bin:/bin:/opt/homebrew/bin:/usr/local/bin"
21
24
  export KB_MODEL_CACHE="${KB_MODEL_CACHE:-/Users/stuartkerr/Code/PowerPlatePulse/scripts/models-cache}"
22
25
 
23
26
  mkdir -p logs