loki-mode 7.91.1 → 7.93.0

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.
package/autonomy/run.sh CHANGED
@@ -1050,6 +1050,179 @@ log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
1050
1050
  log_step() { echo -e "${CYAN}[STEP]${NC} $*"; }
1051
1051
  log_debug() { [[ "${LOKI_DEBUG:-}" == "true" ]] && echo -e "${CYAN}[DEBUG]${NC} $*" >&2 || true; }
1052
1052
 
1053
+ #===============================================================================
1054
+ # Failure diagnosis helpers (T2.4 / T2.5 / T2.6)
1055
+ #
1056
+ # These make a failing or crashed build self-explanatory: a copy-pasteable
1057
+ # "loki why" hint on any non-zero exit, a durable CLASSIFIED LAST_ERROR record
1058
+ # on a failed iteration, and a best-effort terminal record on an untrapped
1059
+ # death. Every one is best-effort and NEVER alters the build's exit code.
1060
+ #===============================================================================
1061
+
1062
+ # _loki_surface_why_hint (T2.4): print the "loki why" hint to stderr and write
1063
+ # it to .loki/NEXT_STEPS.txt. Called once from main() finalization when the run
1064
+ # failed (result != 0). Best-effort: never crashes, never changes exit code.
1065
+ _loki_surface_why_hint() {
1066
+ local loki_dir="${TARGET_DIR:-.}/.loki"
1067
+ local hint="For a plain-language diagnosis of what happened, run: loki why"
1068
+ printf '%s\n' "$hint" >&2 || true
1069
+ mkdir -p "$loki_dir" 2>/dev/null || true
1070
+ printf '%s\n' "$hint" > "$loki_dir/NEXT_STEPS.txt" 2>/dev/null || true
1071
+ return 0
1072
+ }
1073
+
1074
+ # _loki_write_last_error (T2.5): write a durable, classified failure record to
1075
+ # .loki/state/LAST_ERROR.json. Schema:
1076
+ # {
1077
+ # "iteration": <int>,
1078
+ # "error_class": "provider_empty_output"|"build_timeout"|"rate_limited"
1079
+ # |"auth_error"|"unknown",
1080
+ # "brief": "<one honest sentence>",
1081
+ # "timestamp": "<UTC ISO-8601>"
1082
+ # }
1083
+ # This is what `loki why` (in autonomy/loki, NOT owned here) can later read.
1084
+ # Built via python3 so the free-text brief can never break the JSON. Entirely
1085
+ # best-effort: any failure is swallowed and the build is never crashed.
1086
+ # Usage: _loki_write_last_error <iteration> <error_class> <brief>
1087
+ _loki_write_last_error() {
1088
+ local iteration="${1:-0}"
1089
+ local error_class="${2:-unknown}"
1090
+ local brief="${3:-An iteration failed.}"
1091
+ local loki_dir="${TARGET_DIR:-.}/.loki"
1092
+ local state_dir="$loki_dir/state"
1093
+ mkdir -p "$state_dir" 2>/dev/null || true
1094
+ LOKI_LE_ITER="$iteration" \
1095
+ LOKI_LE_CLASS="$error_class" \
1096
+ LOKI_LE_BRIEF="$brief" \
1097
+ LOKI_LE_FILE="$state_dir/LAST_ERROR.json" \
1098
+ python3 -c "
1099
+ import json, os, tempfile
1100
+ try:
1101
+ rec = {
1102
+ 'iteration': int(os.environ.get('LOKI_LE_ITER', '0') or 0),
1103
+ 'error_class': os.environ.get('LOKI_LE_CLASS', 'unknown'),
1104
+ 'brief': os.environ.get('LOKI_LE_BRIEF', ''),
1105
+ 'timestamp': __import__('datetime').datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
1106
+ }
1107
+ target = os.environ['LOKI_LE_FILE']
1108
+ d = os.path.dirname(target)
1109
+ fd, tmp = tempfile.mkstemp(dir=d, suffix='.json')
1110
+ with os.fdopen(fd, 'w') as f:
1111
+ json.dump(rec, f)
1112
+ os.replace(tmp, target)
1113
+ except Exception:
1114
+ pass
1115
+ " 2>/dev/null || true
1116
+ return 0
1117
+ }
1118
+
1119
+ # _loki_classify_iteration_error (T2.5 helper): map an iteration's signals to one
1120
+ # of the LAST_ERROR error_class values. Conservative: only returns a specific
1121
+ # class when a signal confidently supports it, else "unknown". Never fabricates
1122
+ # build_timeout (no timeout signal is detected here, so it is reserved for a
1123
+ # caller that has one). Echoes the class on stdout.
1124
+ # Usage: _loki_classify_iteration_error <iter_output_file> <empty_output_flag 0|1>
1125
+ _loki_classify_iteration_error() {
1126
+ local iter_output="${1:-}"
1127
+ local empty_flag="${2:-0}"
1128
+ if [ "$empty_flag" = "1" ]; then
1129
+ echo "provider_empty_output"
1130
+ return 0
1131
+ fi
1132
+ # Rate limit: reuse the same detector the retry path uses.
1133
+ if [ -n "$iter_output" ] && [ -f "$iter_output" ] && detect_rate_limit "$iter_output" 2>/dev/null | grep -qE '^[1-9]'; then
1134
+ echo "rate_limited"
1135
+ return 0
1136
+ fi
1137
+ # Auth error: a clear 401/403/unauthorized in the output tail.
1138
+ if [ -n "$iter_output" ] && [ -f "$iter_output" ]; then
1139
+ local _tail
1140
+ _tail="$(tail -n 40 "$iter_output" 2>/dev/null || true)"
1141
+ if printf '%s\n' "$_tail" | grep -qiE '(http[ /]?40[13]|status[: ]+40[13]|unauthorized|invalid api key|authentication[_ ]error|401 )' 2>/dev/null; then
1142
+ echo "auth_error"
1143
+ return 0
1144
+ fi
1145
+ fi
1146
+ echo "unknown"
1147
+ return 0
1148
+ }
1149
+
1150
+ # _loki_terminal_record (T2.6, SAFE SUBSET): on an untrapped exit where the
1151
+ # persisted run status is still "running" (a true mid-provider-call crash on a
1152
+ # trappable signal -- SIGTERM/SIGINT/SIGHUP via the lock-release trap), leave a
1153
+ # best-effort, classified LAST_ERROR record so a post-crash `loki why` is not
1154
+ # stale. Piggybacks the existing lock-release EXIT trap (see main()) -- it does
1155
+ # NOT install a new broad EXIT trap.
1156
+ #
1157
+ # IMPORTANT (why this does NOT rewrite autonomy-state.json): the resume-detection
1158
+ # block in this file (search "Durable resume") keys the ENT-2 pod-loss resume on
1159
+ # prev_status == "running". Flipping the status to "exited" here would make a
1160
+ # crashed-but-resumable build (LOKI_DURABLE_STATE=1) reset to iteration 0 on the
1161
+ # next start, destroying durable progress. So this writes ONLY the LAST_ERROR
1162
+ # side-record (which is what `loki why` reads) and deliberately leaves the
1163
+ # status untouched. SIGKILL / power-loss are uncatchable (no trap fires); the
1164
+ # ENT-2 durable-resume path covers those. Never alters the exit code; best-effort.
1165
+ _loki_terminal_record() {
1166
+ local state_file
1167
+ state_file="$(_loki_state_file 2>/dev/null)" || return 0
1168
+ [ -n "$state_file" ] || return 0
1169
+ [ -f "$state_file" ] || return 0
1170
+ local _status
1171
+ _status="$(LOKI_TR_FILE="$state_file" python3 -c "
1172
+ import json, os
1173
+ try:
1174
+ print(json.load(open(os.environ['LOKI_TR_FILE'])).get('status','unknown'))
1175
+ except Exception:
1176
+ print('unknown')
1177
+ " 2>/dev/null || echo "unknown")"
1178
+ # Only act on a genuinely mid-flight "running" status. Any settled status
1179
+ # (council_approved, failed, exited, paused, ...) is left untouched.
1180
+ [ "$_status" = "running" ] || return 0
1181
+ # Leave a classified LAST_ERROR so `loki why` has something honest -- WITHOUT
1182
+ # touching autonomy-state.json (preserving the ENT-2 "running" resume signal).
1183
+ _loki_write_last_error "${ITERATION_COUNT:-0}" "unknown" \
1184
+ "The build process exited unexpectedly before finishing (possible crash or kill)." 2>/dev/null || true
1185
+ return 0
1186
+ }
1187
+
1188
+ # _loki_write_rate_limit_signal (T2.7): write a best-effort
1189
+ # .loki/signals/RATE_LIMITED file. This is FORWARD-LAID infrastructure: no
1190
+ # consumer reads it yet (a future dashboard / external watcher could, to tell a
1191
+ # normal provider rate-limit wait apart from a hang). The user-visible signal
1192
+ # today is the log_info line at the wait site; this file is the durable record. Schema:
1193
+ # {"rate_limited": true, "wait_seconds": <int>, "reset_time": "<string>"}
1194
+ # Built via python3 so the reset-time string can never break the JSON. Entirely
1195
+ # best-effort: never crashes, never alters the build.
1196
+ # Usage: _loki_write_rate_limit_signal <wait_seconds> <reset_time>
1197
+ _loki_write_rate_limit_signal() {
1198
+ local wait_seconds="${1:-0}"
1199
+ local reset_time="${2:-}"
1200
+ local signals_dir="${TARGET_DIR:-.}/.loki/signals"
1201
+ mkdir -p "$signals_dir" 2>/dev/null || true
1202
+ LOKI_RL_WAIT="$wait_seconds" \
1203
+ LOKI_RL_RESET="$reset_time" \
1204
+ LOKI_RL_FILE="$signals_dir/RATE_LIMITED" \
1205
+ python3 -c "
1206
+ import json, os, tempfile
1207
+ try:
1208
+ w = os.environ.get('LOKI_RL_WAIT', '0')
1209
+ try:
1210
+ w = int(w)
1211
+ except Exception:
1212
+ w = 0
1213
+ rec = {'rate_limited': True, 'wait_seconds': w, 'reset_time': os.environ.get('LOKI_RL_RESET', '')}
1214
+ target = os.environ['LOKI_RL_FILE']
1215
+ d = os.path.dirname(target)
1216
+ fd, tmp = tempfile.mkstemp(dir=d, suffix='.json')
1217
+ with os.fdopen(fd, 'w') as f:
1218
+ json.dump(rec, f)
1219
+ os.replace(tmp, target)
1220
+ except Exception:
1221
+ pass
1222
+ " 2>/dev/null || true
1223
+ return 0
1224
+ }
1225
+
1053
1226
  # Live Build HUD (v7.71.0): a single append-only per-iteration status line on the
1054
1227
  # interactive TTY path. Pure additive stdout decoration -- never piped into any
1055
1228
  # tee, so the dashboard agent.log and the stream-json parser are untouched. The
@@ -1798,9 +1971,93 @@ get_iteration_duration_ms() {
1798
1971
  # Supports Docker/K8s secret file mounts as fallback.
1799
1972
  #===============================================================================
1800
1973
 
1974
+ # Zero-friction preflight helpers (T1.1). These run for ALL environments
1975
+ # (not just Docker/K8s) BEFORE the build starts, via validate_api_keys. git is a
1976
+ # genuine hard requirement (the build inits a repo) so a missing git BLOCKS with a
1977
+ # copy-pasteable fix; node-version and network reachability are ADVISORY (warn and
1978
+ # continue, fail-open) so a probabilistic or optional signal never blocks a working
1979
+ # user. The goal: surface real problems early without ever wrongly refusing to start.
1980
+ #
1981
+ # _loki_check_node_version: ADVISORY only. If node is present and its major
1982
+ # version is < 18, log a warning (node only matters for node-based builds) and
1983
+ # continue. If node is absent entirely this is a NO-OP. Always returns 0 -- it
1984
+ # never blocks the build (fail-open); the real node call, if any, is the test.
1985
+ _loki_check_node_version() {
1986
+ command -v node >/dev/null 2>&1 || return 0
1987
+ local node_version major
1988
+ node_version="$(node --version 2>/dev/null || echo '')"
1989
+ # node --version -> "v20.11.0"; extract the leading major integer.
1990
+ major="$(printf '%s' "$node_version" | sed -E 's/^v?([0-9]+).*/\1/')"
1991
+ # Advisory only: node is OPTIONAL (absence is a no-op above, and many builds -
1992
+ # Python/Go/Rust - never touch node). A present-but-old node only matters for
1993
+ # node-based builds, so WARN and continue (fail-open); never hard-block a
1994
+ # working user. The actual node call (only for JS/TS work) is the real test.
1995
+ if [ -n "$major" ] && [[ "$major" =~ ^[0-9]+$ ]] && [ "$major" -lt 18 ]; then
1996
+ log_warn "Node.js >= 18 recommended for node-based builds; found ${node_version:-unknown}. Upgrade if your project uses node: https://nodejs.org"
1997
+ fi
1998
+ return 0
1999
+ }
2000
+
2001
+ # _loki_check_git_present: the build initializes a git repo, so git is required.
2002
+ _loki_check_git_present() {
2003
+ if ! command -v git >/dev/null 2>&1; then
2004
+ log_error "Git is required (the build initializes a repo). Install: https://git-scm.com/downloads"
2005
+ return 1
2006
+ fi
2007
+ return 0
2008
+ }
2009
+
2010
+ # _loki_check_network_reachable: ADVISORY only. A fast (3s) reachability probe to
2011
+ # the active provider endpoint that WARNS and continues if it cannot reach it -- a
2012
+ # curl failure does not prove the provider CLI cannot connect (3s timeout under
2013
+ # load, transient DNS, or a proxy set for the CLI but not the shell all curl-fail
2014
+ # while the real build succeeds). Always returns 0 (fail-open); the actual
2015
+ # provider call is the authoritative connectivity test. Skipped entirely when curl
2016
+ # is missing, when LOKI_SKIP_NET_PREFLIGHT=1, when ANTHROPIC_BASE_URL is set (alt
2017
+ # provider endpoint we cannot assume), or for any provider whose endpoint we do
2018
+ # not know. It NEVER blocks the build.
2019
+ _loki_check_network_reachable() {
2020
+ local provider="${1:-claude}"
2021
+ [ "${LOKI_SKIP_NET_PREFLIGHT:-}" = "1" ] && return 0
2022
+ command -v curl >/dev/null 2>&1 || return 0
2023
+ # Alternate provider base URL set -> do not assume the default endpoint.
2024
+ [ -n "${ANTHROPIC_BASE_URL:-}" ] && return 0
2025
+
2026
+ local endpoint=""
2027
+ case "$provider" in
2028
+ claude) endpoint="https://api.anthropic.com" ;;
2029
+ *) return 0 ;; # unknown endpoint -> fail open, never guess
2030
+ esac
2031
+
2032
+ # Advisory only: a fast curl probe failing does NOT prove the provider CLI
2033
+ # cannot connect (a 3s timeout under load, transient DNS, or a proxy set for
2034
+ # the CLI but not the shell all curl-fail while the real build succeeds). WARN
2035
+ # and continue - the actual provider call is the authoritative connectivity
2036
+ # test. Silence this with LOKI_SKIP_NET_PREFLIGHT=1. Never hard-block here.
2037
+ if ! curl -sS -m 3 -o /dev/null "$endpoint" 2>/dev/null; then
2038
+ log_warn "Could not verify network reachability to the AI provider (firewall/VPN/transient?). Continuing; the provider call will be the real test. Silence with LOKI_SKIP_NET_PREFLIGHT=1."
2039
+ fi
2040
+ return 0
2041
+ }
2042
+
1801
2043
  validate_api_keys() {
1802
2044
  local provider="${LOKI_PROVIDER:-claude}"
1803
2045
 
2046
+ # Zero-friction preflight (T1.1): toolchain + reachability checks that apply
2047
+ # to EVERY environment, run BEFORE the Docker/K8s early-return below so they
2048
+ # are not silently skipped in the common local case. Node/git are genuinely
2049
+ # required (node only when present-but-too-old); the network probe is
2050
+ # fail-open and opt-out (LOKI_SKIP_NET_PREFLIGHT=1).
2051
+ if ! _loki_check_node_version; then
2052
+ return 1
2053
+ fi
2054
+ if ! _loki_check_git_present; then
2055
+ return 1
2056
+ fi
2057
+ if ! _loki_check_network_reachable "$provider"; then
2058
+ return 1
2059
+ fi
2060
+
1804
2061
  # CLI tools (claude, codex, cline, aider) use their own login sessions.
1805
2062
  # Only require API keys inside Docker/K8s where CLI login isn't available.
1806
2063
  if [[ ! -f "/.dockerenv" ]] && [[ -z "${KUBERNETES_SERVICE_HOST:-}" ]]; then
@@ -16170,9 +16427,13 @@ if __name__ == "__main__":
16170
16427
  esac
16171
16428
 
16172
16429
  # BUG-EC-013: Detect empty provider output (0 bytes = no work done)
16430
+ # T2.5: track this distinct cause so the failure path can classify the
16431
+ # durable LAST_ERROR record as provider_empty_output specifically.
16432
+ local _empty_output=0
16173
16433
  if [ -f "$iter_output" ] && [ ! -s "$iter_output" ] && [ $exit_code -eq 0 ]; then
16174
16434
  log_warn "Provider returned empty output (0 bytes) despite exit code 0 -- treating as error"
16175
16435
  exit_code=1
16436
+ _empty_output=1
16176
16437
  fi
16177
16438
 
16178
16439
  save_state $retry "exited" $exit_code
@@ -16967,6 +17228,24 @@ else:
16967
17228
  # the "Will retry" log_warn below. TTY-gated, `|| true`, never tee'd.
16968
17229
  render_build_hud "${ITERATION_COUNT:-0}" "${rarv_phase:-?}" "${duration:-0}" || true
16969
17230
 
17231
+ # T2.5: durable, classified failure record for `loki why`. Best-effort,
17232
+ # never crashes the build. Skip signal-induced exits (130 SIGINT /
17233
+ # 143 SIGTERM / 137 SIGKILL): a user/operator interrupt is not an error
17234
+ # to record (mirrors the crash-capture exclusion above). Classification
17235
+ # is conservative -- provider_empty_output | rate_limited | auth_error,
17236
+ # else unknown; never fabricates a class that no signal supports.
17237
+ if [ "$exit_code" -ne 130 ] && [ "$exit_code" -ne 143 ] && [ "$exit_code" -ne 137 ]; then
17238
+ local _err_class _err_brief
17239
+ _err_class="$(_loki_classify_iteration_error "$iter_output" "${_empty_output:-0}")"
17240
+ case "$_err_class" in
17241
+ provider_empty_output) _err_brief="The provider returned no output (0 bytes) on this iteration -- no work was done." ;;
17242
+ rate_limited) _err_brief="The provider rate-limited the request; the build will wait and retry." ;;
17243
+ auth_error) _err_brief="The provider rejected the request as unauthorized (check your login or API key)." ;;
17244
+ *) _err_brief="Iteration ${ITERATION_COUNT:-?} failed with exit code ${exit_code} (cause not classified)." ;;
17245
+ esac
17246
+ _loki_write_last_error "${ITERATION_COUNT:-0}" "$_err_class" "$_err_brief" || true
17247
+ fi
17248
+
16970
17249
  # Checkpoint failed iteration state (v5.57.0)
16971
17250
  create_checkpoint "iteration-${ITERATION_COUNT} failed (exit=$exit_code)" "iteration-${ITERATION_COUNT}-fail"
16972
17251
 
@@ -16986,7 +17265,15 @@ else:
16986
17265
  wait_time=$rate_limit_wait
16987
17266
  local human_time=$(format_duration $wait_time)
16988
17267
  log_warn "Rate limit detected! Waiting until reset (~$human_time)..."
16989
- log_info "Rate limit resets at approximately $(date -v+${wait_time}S '+%I:%M %p' 2>/dev/null || date -d "+${wait_time} seconds" '+%I:%M %p' 2>/dev/null || echo 'soon')"
17268
+ local _reset_at
17269
+ _reset_at="$(date -v+${wait_time}S '+%I:%M %p' 2>/dev/null || date -d "+${wait_time} seconds" '+%I:%M %p' 2>/dev/null || echo 'soon')"
17270
+ log_info "Rate limit resets at approximately $_reset_at"
17271
+ # T2.7: elevate the wait to an explicit, reassuring INFO line (the
17272
+ # human time was previously only at DEBUG inside detect_rate_limit's
17273
+ # calculated-backoff branch) so a multi-minute wait does not look
17274
+ # like a hang, and persist a machine-readable signal for watchers.
17275
+ log_info "Rate-limited by the provider; waiting ~${wait_time}s (resets ${_reset_at}). This is normal, not a hang."
17276
+ _loki_write_rate_limit_signal "$wait_time" "$_reset_at" || true
16990
17277
  notify_rate_limit "$wait_time"
16991
17278
  else
16992
17279
  wait_time=$(calculate_wait $retry)
@@ -17026,6 +17313,10 @@ else:
17026
17313
  done
17027
17314
  echo ""
17028
17315
 
17316
+ # T2.7: the wait is over -- clear the RATE_LIMITED signal so it never
17317
+ # lingers stale once the build resumes. Best-effort.
17318
+ rm -f "${TARGET_DIR:-.}/.loki/signals/RATE_LIMITED" 2>/dev/null || true
17319
+
17029
17320
  # Clean up per-iteration output file
17030
17321
  rm -f "$iter_output" 2>/dev/null
17031
17322
 
@@ -18010,8 +18301,15 @@ main() {
18010
18301
  fi
18011
18302
  # Release on session-process exit so a fresh `loki start` can
18012
18303
  # immediately re-acquire after this one finishes / is killed.
18304
+ # T2.6: piggyback the existing lock-release trap (we deliberately do NOT
18305
+ # add a new broad EXIT trap) to write a best-effort terminal record on an
18306
+ # untrapped non-zero exit where the run status is still "running" -- so a
18307
+ # post-crash `loki why` is not stale. _loki_terminal_record is a strict
18308
+ # no-op on every graceful path (status already settled) and never alters
18309
+ # the exit code. SIGKILL/power-loss stay uncatchable (no trap fires); the
18310
+ # ENT-2 durable-resume path covers those.
18013
18311
  # shellcheck disable=SC2064
18014
- trap "safe_release_lock '$lock_file'" EXIT INT TERM HUP
18312
+ trap "_loki_terminal_record || true; safe_release_lock '$lock_file'" EXIT INT TERM HUP
18015
18313
 
18016
18314
  # Check PID file after acquiring lock
18017
18315
  if [ -f "$pid_file" ]; then
@@ -18174,6 +18472,13 @@ main() {
18174
18472
  fi
18175
18473
  fi
18176
18474
 
18475
+ # Clear any stale per-run diagnosis record from a PRIOR run before this one
18476
+ # starts. LAST_ERROR.json is a single side-record; if it survived a previous
18477
+ # failed run it must not surface next to THIS run's outcome (a stale error
18478
+ # shown beside a fresh success would be a fake-green-adjacent lie). Mirrors
18479
+ # the RATE_LIMITED signal clear. Best-effort; never blocks the run.
18480
+ rm -f "${TARGET_DIR:-.}/.loki/state/LAST_ERROR.json" 2>/dev/null || true
18481
+
18177
18482
  if [ "$PARALLEL_MODE" = "true" ]; then
18178
18483
  # Parallel mode: orchestrate multiple worktrees
18179
18484
  log_header "Running in Parallel Mode"
@@ -18423,6 +18728,13 @@ except (json.JSONDecodeError, OSError): pass
18423
18728
  " 2>/dev/null || true
18424
18729
  fi
18425
18730
 
18731
+ # T2.4: on ANY non-zero final result, surface a plain-language next step
18732
+ # (print to stderr + write .loki/NEXT_STEPS.txt). Single chokepoint at the
18733
+ # finalization exit so it fires once and never double-prints on success.
18734
+ if [ "$result" != "0" ]; then
18735
+ _loki_surface_why_hint || true
18736
+ fi
18737
+
18426
18738
  exit $result
18427
18739
  }
18428
18740
 
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.91.1"
10
+ __version__ = "7.93.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -667,7 +667,14 @@ async def _push_loki_state_loop() -> None:
667
667
  "running_agents": running_agents,
668
668
  "pending_tasks": len(pending) if isinstance(pending, list) else 0,
669
669
  "current_task": in_prog[0].get("payload", {}).get("action", "") if isinstance(in_prog, list) and in_prog else "",
670
- "version": raw.get("version", ""),
670
+ # Version reflects the RUNNING engine, not the project's
671
+ # stored state. raw.get("version") is whatever engine
672
+ # last wrote dashboard-state.json for THIS project (can
673
+ # be an old version, e.g. a project first built under
674
+ # 7.7.29), which made the displayed version flip between
675
+ # this stale value and the live one from the fallback
676
+ # path below. Always use the live engine version.
677
+ "version": _version,
671
678
  }
672
679
  await manager.broadcast({
673
680
  "type": "status_update",
@@ -705,14 +712,10 @@ async def _push_loki_state_loop() -> None:
705
712
  pass
706
713
 
707
714
  if _sk_fresh:
708
- # Read version from VERSION file
709
- _sk_version = ""
710
- _vf = _Path(os.path.dirname(os.path.dirname(__file__))) / "VERSION"
711
- if _vf.is_file():
712
- try:
713
- _sk_version = _vf.read_text().strip()
714
- except OSError:
715
- pass
715
+ # Version reflects the running engine (single source of
716
+ # truth), the same value the dashboard-state path uses
717
+ # above, so the displayed version never flips.
718
+ _sk_version = _version
716
719
 
717
720
  # Read orchestrator state
718
721
  _sk_phase = ""
@@ -1106,17 +1109,10 @@ async def get_status() -> StatusResponse:
1106
1109
  loki_dir = _get_loki_dir()
1107
1110
  uptime = (datetime.now(timezone.utc) - start_time).total_seconds()
1108
1111
 
1109
- # Read VERSION file early (independent of .loki/ dir)
1110
- version = "unknown"
1111
- dashboard_dir = os.path.dirname(__file__)
1112
- project_root = os.path.dirname(dashboard_dir)
1113
- version_file = os.path.join(project_root, "VERSION")
1114
- if os.path.isfile(version_file):
1115
- try:
1116
- with open(version_file) as vf:
1117
- version = vf.read().strip()
1118
- except (OSError, IOError) as e:
1119
- logger.warning(f"Failed to read VERSION file: {e}")
1112
+ # Version reflects the running engine (single source of truth: the package
1113
+ # __version__, same value every status path uses) so the displayed version is
1114
+ # always the live engine, never a stale per-project value.
1115
+ version = _version
1120
1116
 
1121
1117
  # If .loki/ directory doesn't exist, return idle status immediately
1122
1118
  if not loki_dir.is_dir():
@@ -8784,6 +8780,157 @@ def _reconcile_app_runner_liveness(state):
8784
8780
  return state
8785
8781
 
8786
8782
 
8783
+ # Per-probe TCP connect timeout (seconds) for the single-process port probe.
8784
+ # Kept short so a stopped/firewalled port fails fast and the status endpoint
8785
+ # stays responsive for the dashboard's 3-5s pollers.
8786
+ _APP_RUNNER_PORT_PROBE_TIMEOUT = 1.0
8787
+
8788
+
8789
+ def _port_is_serving(port):
8790
+ """True only if a TCP connection to 127.0.0.1:<port> genuinely succeeds.
8791
+
8792
+ Honest by construction: this proves *something* is accepting connections on
8793
+ the recorded port right now, never fabricates a result. Any failure (refused,
8794
+ timeout, bad port, OS error) returns False so the caller degrades to an
8795
+ honest non-running state. Synchronous; the caller offloads it to a worker
8796
+ thread so the event loop is never blocked by the connect.
8797
+ """
8798
+ try:
8799
+ port = int(port)
8800
+ except (TypeError, ValueError):
8801
+ return False
8802
+ if port <= 0 or port > 65535:
8803
+ return False
8804
+ import socket
8805
+ for host in ("127.0.0.1", "::1"):
8806
+ sock = None
8807
+ try:
8808
+ family = socket.AF_INET6 if ":" in host else socket.AF_INET
8809
+ sock = socket.socket(family, socket.SOCK_STREAM)
8810
+ sock.settimeout(_APP_RUNNER_PORT_PROBE_TIMEOUT)
8811
+ if sock.connect_ex((host, port)) == 0:
8812
+ return True
8813
+ except OSError:
8814
+ continue
8815
+ finally:
8816
+ if sock is not None:
8817
+ try:
8818
+ sock.close()
8819
+ except OSError:
8820
+ pass
8821
+ return False
8822
+
8823
+
8824
+ def _dashboard_self_port():
8825
+ """The TCP port this dashboard process itself is bound to, or None.
8826
+
8827
+ The user's app can never listen on the port the dashboard already occupies,
8828
+ so this is used to exclude a self-hit from the single-process probe (a stale
8829
+ recorded port that happens to equal the dashboard's port would otherwise
8830
+ probe-succeed against the dashboard and be misreported as the app running).
8831
+ Reads LOKI_DASHBOARD_PORT (the same env run_server binds), defaulting to the
8832
+ well-known 57374. Returns an int or None.
8833
+ """
8834
+ try:
8835
+ return int(os.environ.get("LOKI_DASHBOARD_PORT", "57374"))
8836
+ except (TypeError, ValueError):
8837
+ return 57374
8838
+
8839
+
8840
+ def _recorded_app_port(state):
8841
+ """Best-effort recorded port for the single-process app, or None.
8842
+
8843
+ Reads the port the app-runner/CLI already recorded for THIS project, never a
8844
+ guessed value: first state.json's own `port`, then the app-runner
8845
+ detection.json the engine writes (`.loki/app-runner/detection.json`). Returns
8846
+ an int in the valid range or None. Pairing the probe to a *recorded* port is
8847
+ what keeps the result honest -- we never sweep arbitrary common ports.
8848
+
8849
+ Honesty guards:
8850
+ - A docker-compose detection.json is ignored here; a compose stack is the
8851
+ compose-discovery path's domain (which verifies the container belongs to
8852
+ the project), so feeding its port into the single-process probe would
8853
+ risk a false positive against an unrelated local service.
8854
+ - The dashboard's own port is never returned, since the user's app cannot
8855
+ bind it and a stale recorded value equal to it would self-hit the probe.
8856
+ """
8857
+ self_port = _dashboard_self_port()
8858
+
8859
+ def _ok(p):
8860
+ try:
8861
+ p = int(p)
8862
+ except (TypeError, ValueError):
8863
+ return None
8864
+ if not (0 < p <= 65535):
8865
+ return None
8866
+ if self_port is not None and p == self_port:
8867
+ return None
8868
+ return p
8869
+
8870
+ if isinstance(state, dict):
8871
+ p = _ok(state.get("port"))
8872
+ if p is not None:
8873
+ return p
8874
+ try:
8875
+ det_file = _get_loki_dir() / "app-runner" / "detection.json"
8876
+ if det_file.is_file():
8877
+ det = json.loads(det_file.read_text())
8878
+ if isinstance(det, dict) and not det.get("is_docker"):
8879
+ p = _ok(det.get("port"))
8880
+ if p is not None:
8881
+ return p
8882
+ except (OSError, ValueError, TypeError, json.JSONDecodeError):
8883
+ pass
8884
+ return None
8885
+
8886
+
8887
+ def _discover_single_process_app_runner_state(state):
8888
+ """Detect a genuinely-running single-process app via a recorded-port probe.
8889
+
8890
+ A SKILL/CLI-built project (e.g. one started with `npm run dev` outside
8891
+ app-runner.sh, or whose orchestrator has since exited) leaves a state.json
8892
+ with status "stopped"/"stale" and main_pid 0, even while the app itself is
8893
+ still serving. The dashboard would then report "not running" for a live app.
8894
+
8895
+ Resolution (all probe-verified, never fabricated):
8896
+ - Find the RECORDED port (state.json.port or non-docker detection.json) --
8897
+ never a guessed port, and never the dashboard's own port.
8898
+ - Probe 127.0.0.1:<port>. Only if the connection genuinely succeeds do we
8899
+ synthesize a "running" state using the recorded url/port.
8900
+ - Otherwise (no recorded port, or recorded port not reachable) return None;
8901
+ the caller keeps the honest reconciled state, which already carries
8902
+ positive evidence (the writer's settled "stopped", or a pid_gone /
8903
+ recycled / health_stale downgrade). "Unknown" would be less honest than
8904
+ that evidence, so we never override it here.
8905
+
8906
+ Synchronous and self-contained; the caller offloads it onto a worker thread.
8907
+ Never raises.
8908
+ """
8909
+ try:
8910
+ port = _recorded_app_port(state)
8911
+ if not port:
8912
+ return None
8913
+ if not _port_is_serving(port):
8914
+ return None
8915
+ url = ""
8916
+ if isinstance(state, dict):
8917
+ url = state.get("url") or ""
8918
+ if not url:
8919
+ url = "http://localhost:{}".format(port)
8920
+ return {
8921
+ "status": "running",
8922
+ "url": url,
8923
+ "port": int(port),
8924
+ "method": (state.get("method") if isinstance(state, dict) else "") or "",
8925
+ "source": "probe",
8926
+ "externally_managed": True,
8927
+ "last_health": {"ok": True},
8928
+ }
8929
+ except Exception:
8930
+ # Fail open: never let the probe break the status endpoint.
8931
+ return None
8932
+
8933
+
8787
8934
  # =============================================================================
8788
8935
  # Docker-compose app-runner discovery
8789
8936
  #
@@ -9191,6 +9338,10 @@ async def get_app_runner_status():
9191
9338
  discovered = await asyncio.to_thread(_discover_compose_app_runner_state)
9192
9339
  if discovered is not None:
9193
9340
  return discovered
9341
+ # No state.json at all and no compose stack: there is no recorded port to
9342
+ # probe, so the single-process probe can only ever return an honest
9343
+ # "unknown" here. Keep returning not_initialized (the project has never
9344
+ # had an app-runner record) rather than overstating with "unknown".
9194
9345
  return {"status": "not_initialized"}
9195
9346
 
9196
9347
  try:
@@ -9208,6 +9359,14 @@ async def get_app_runner_status():
9208
9359
  discovered = await asyncio.to_thread(_discover_compose_app_runner_state)
9209
9360
  if discovered is not None:
9210
9361
  return discovered
9362
+
9363
+ # Still nothing from compose: a SKILL/CLI-built project may be serving on its
9364
+ # recorded port even though no live app-runner.sh process owns it (main_pid 0
9365
+ # / orchestrator exited). Probe the RECORDED port and only report running when
9366
+ # the port genuinely answers; otherwise keep the honest reconciled state.
9367
+ probed = await asyncio.to_thread(_discover_single_process_app_runner_state, state)
9368
+ if isinstance(probed, dict) and probed.get("status") == "running":
9369
+ return probed
9211
9370
  return reconciled
9212
9371
 
9213
9372