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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +154 -27
- package/autonomy/provider-offer.sh +8 -1
- package/autonomy/run.sh +314 -2
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +179 -20
- package/dashboard/static/index.html +423 -104
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +5 -5
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
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
|
-
|
|
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
|
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
#
|
|
1110
|
-
version
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
|