loki-mode 7.5.10 → 7.5.12
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/README.md +29 -4
- package/SKILL.md +17 -14
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +81 -6
- package/autonomy/lib/lock.sh +147 -0
- package/autonomy/loki +22 -0
- package/autonomy/run.sh +332 -69
- package/dashboard/__init__.py +1 -1
- package/dashboard/database.py +26 -0
- package/dashboard/models.py +8 -0
- package/dashboard/server.py +125 -4
- package/dashboard/static/index.html +361 -172
- package/docs/COMPARISON.md +6 -6
- package/docs/INSTALLATION.md +32 -22
- package/docs/cursor-comparison.md +6 -6
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +2 -2
package/autonomy/run.sh
CHANGED
|
@@ -601,6 +601,17 @@ PERPETUAL_MODE=${LOKI_PERPETUAL_MODE:-false}
|
|
|
601
601
|
# Enterprise background service PIDs (OTEL bridge, audit subscriber, integration sync)
|
|
602
602
|
ENTERPRISE_PIDS=()
|
|
603
603
|
|
|
604
|
+
# Portable lock helper (v7.5.12) -- mkdir-mutex replacement for flock(1).
|
|
605
|
+
# Provides safe_acquire_lock / safe_release_lock / safe_with_lock so bash
|
|
606
|
+
# callers no longer need a Linux-only flock binary. Macs do not ship
|
|
607
|
+
# flock; pre-7.5.12 the fallback was a non-atomic PID check that emitted
|
|
608
|
+
# "[WARN] flock not available - using non-atomic PID check ...".
|
|
609
|
+
LOCK_LIB="$SCRIPT_DIR/lib/lock.sh"
|
|
610
|
+
if [ -f "$LOCK_LIB" ]; then
|
|
611
|
+
# shellcheck source=lib/lock.sh
|
|
612
|
+
source "$LOCK_LIB"
|
|
613
|
+
fi
|
|
614
|
+
|
|
604
615
|
# Completion Council (v5.25.0) - Multi-agent completion verification
|
|
605
616
|
# Source completion council module
|
|
606
617
|
COUNCIL_SCRIPT="$SCRIPT_DIR/completion-council.sh"
|
|
@@ -1825,23 +1836,23 @@ import_github_issues() {
|
|
|
1825
1836
|
created_at: $created
|
|
1826
1837
|
}')
|
|
1827
1838
|
|
|
1828
|
-
# BUG-XC-010: Create temp file in same directory as target (avoids cross-filesystem mv)
|
|
1829
|
-
#
|
|
1839
|
+
# BUG-XC-010: Create temp file in same directory as target (avoids cross-filesystem mv).
|
|
1840
|
+
# v7.5.12: replace flock-only queue lock with portable mkdir-mutex via
|
|
1841
|
+
# safe_acquire_lock (works on macOS without util-linux flock).
|
|
1830
1842
|
local temp_file
|
|
1831
1843
|
temp_file=$(mktemp ".loki/queue/pending.json.tmp.XXXXXX")
|
|
1832
1844
|
local lockfile=".loki/queue/.pending.lock"
|
|
1833
|
-
|
|
1834
|
-
if ! flock -w 5 200 2>/dev/null; then
|
|
1835
|
-
log_warn "Could not acquire queue lock for issue #$number, skipping"
|
|
1836
|
-
exit 1
|
|
1837
|
-
fi
|
|
1845
|
+
if type safe_acquire_lock >/dev/null 2>&1 && safe_acquire_lock "$lockfile" 5; then
|
|
1838
1846
|
if jq ". += [$task_json]" "$pending_file" > "$temp_file" && mv "$temp_file" "$pending_file"; then
|
|
1839
1847
|
log_info "Imported issue #$number: $title"
|
|
1840
1848
|
task_count=$((task_count + 1))
|
|
1841
1849
|
else
|
|
1842
1850
|
log_warn "Failed to import issue #$number"
|
|
1843
1851
|
fi
|
|
1844
|
-
|
|
1852
|
+
safe_release_lock "$lockfile"
|
|
1853
|
+
else
|
|
1854
|
+
log_warn "Could not acquire queue lock for issue #$number, skipping"
|
|
1855
|
+
fi
|
|
1845
1856
|
rm -f "$temp_file"
|
|
1846
1857
|
done < <(echo "$issues" | jq -c '.[]')
|
|
1847
1858
|
|
|
@@ -3018,9 +3029,14 @@ check_skill_installed() {
|
|
|
3018
3029
|
init_loki_dir() {
|
|
3019
3030
|
log_header "Initializing Loki Mode Directory"
|
|
3020
3031
|
|
|
3021
|
-
# Clean up stale control files ONLY if no other session is running
|
|
3022
|
-
# Deleting these while another session is active would destroy its signals
|
|
3023
|
-
#
|
|
3032
|
+
# Clean up stale control files ONLY if no other session is running.
|
|
3033
|
+
# Deleting these while another session is active would destroy its signals.
|
|
3034
|
+
#
|
|
3035
|
+
# v7.5.12: PID-liveness probe replaces flock-based "is the lock held?"
|
|
3036
|
+
# check. The mkdir-mutex used by safe_acquire_lock is not introspectable
|
|
3037
|
+
# the same way (no FD to non-blocking-poll), but the PID file is the
|
|
3038
|
+
# source of truth for liveness anyway -- a stale lockdir without a
|
|
3039
|
+
# live owner means the session is gone, so cleanup is safe.
|
|
3024
3040
|
#
|
|
3025
3041
|
# Per-session locking (v6.4.0): When LOKI_SESSION_ID is set, only clean up
|
|
3026
3042
|
# that session's files. Global control files (PAUSE/STOP) are only cleaned
|
|
@@ -3028,37 +3044,30 @@ init_loki_dir() {
|
|
|
3028
3044
|
local lock_file can_cleanup=false
|
|
3029
3045
|
|
|
3030
3046
|
if [ -n "${LOKI_SESSION_ID:-}" ]; then
|
|
3031
|
-
# Per-session:
|
|
3047
|
+
# Per-session: PID-liveness probe
|
|
3032
3048
|
lock_file=".loki/sessions/${LOKI_SESSION_ID}/session.lock"
|
|
3033
3049
|
local session_pid_file=".loki/sessions/${LOKI_SESSION_ID}/loki.pid"
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
fi
|
|
3041
|
-
if [ -z "$existing_pid" ] || ! kill -0 "$existing_pid" 2>/dev/null; then
|
|
3042
|
-
can_cleanup=true
|
|
3043
|
-
fi
|
|
3050
|
+
local existing_pid=""
|
|
3051
|
+
if [ -f "$session_pid_file" ]; then
|
|
3052
|
+
existing_pid=$(cat "$session_pid_file" 2>/dev/null)
|
|
3053
|
+
fi
|
|
3054
|
+
if [ -z "$existing_pid" ] || ! kill -0 "$existing_pid" 2>/dev/null; then
|
|
3055
|
+
can_cleanup=true
|
|
3044
3056
|
fi
|
|
3045
3057
|
if [ "$can_cleanup" = "true" ]; then
|
|
3046
3058
|
rm -f "$session_pid_file" 2>/dev/null
|
|
3047
3059
|
rm -f "$lock_file" 2>/dev/null
|
|
3060
|
+
rm -rf "${lock_file}.lockdir" 2>/dev/null
|
|
3048
3061
|
fi
|
|
3049
3062
|
else
|
|
3050
|
-
# Global:
|
|
3063
|
+
# Global: PID-liveness probe
|
|
3051
3064
|
lock_file=".loki/session.lock"
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
fi
|
|
3059
|
-
if [ -z "$existing_pid" ] || ! kill -0 "$existing_pid" 2>/dev/null; then
|
|
3060
|
-
can_cleanup=true
|
|
3061
|
-
fi
|
|
3065
|
+
local existing_pid=""
|
|
3066
|
+
if [ -f ".loki/loki.pid" ]; then
|
|
3067
|
+
existing_pid=$(cat ".loki/loki.pid" 2>/dev/null)
|
|
3068
|
+
fi
|
|
3069
|
+
if [ -z "$existing_pid" ] || ! kill -0 "$existing_pid" 2>/dev/null; then
|
|
3070
|
+
can_cleanup=true
|
|
3062
3071
|
fi
|
|
3063
3072
|
if [ "$can_cleanup" = "true" ]; then
|
|
3064
3073
|
# v7.4.16: extended stale-signal cleanup. Pre-v7.4.16 only
|
|
@@ -3073,6 +3082,7 @@ init_loki_dir() {
|
|
|
3073
3082
|
rm -f .loki/PAUSE_AT_CHECKPOINT .loki/PAUSED.md .loki/COMPLETED 2>/dev/null
|
|
3074
3083
|
rm -f .loki/loki.pid 2>/dev/null
|
|
3075
3084
|
rm -f .loki/session.lock 2>/dev/null
|
|
3085
|
+
rm -rf .loki/session.lock.lockdir 2>/dev/null
|
|
3076
3086
|
fi
|
|
3077
3087
|
fi
|
|
3078
3088
|
|
|
@@ -3453,6 +3463,73 @@ os.replace(tmp, orch_file)
|
|
|
3453
3463
|
fi
|
|
3454
3464
|
|
|
3455
3465
|
LAST_KNOWN_PHASE="$new_phase"
|
|
3466
|
+
|
|
3467
|
+
# v7.5.12: Append a structured log entry to the active iteration task so
|
|
3468
|
+
# the dashboard shows per-phase progress (REASON / ACT / REFLECT / VERIFY).
|
|
3469
|
+
# No-op if no iteration is active or queue file is missing/corrupt.
|
|
3470
|
+
append_iteration_task_log "${ITERATION_COUNT:-0}" "$new_phase" "info" \
|
|
3471
|
+
"Phase entered: $new_phase" 2>/dev/null || true
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
# v7.5.12: append a log entry to the iteration-N task in in-progress.json.
|
|
3475
|
+
# Args: iteration, phase, level, message. All silent on failure -- this
|
|
3476
|
+
# must NEVER kill the run.
|
|
3477
|
+
append_iteration_task_log() {
|
|
3478
|
+
local iteration="${1:-0}"
|
|
3479
|
+
local phase="${2:-}"
|
|
3480
|
+
local level="${3:-info}"
|
|
3481
|
+
local message="${4:-}"
|
|
3482
|
+
local in_progress_file=".loki/queue/in-progress.json"
|
|
3483
|
+
|
|
3484
|
+
[ -z "$iteration" ] && return 0
|
|
3485
|
+
[ "$iteration" = "0" ] && return 0
|
|
3486
|
+
[ ! -f "$in_progress_file" ] && return 0
|
|
3487
|
+
|
|
3488
|
+
local timestamp
|
|
3489
|
+
timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
3490
|
+
|
|
3491
|
+
ITER="$iteration" PHASE="$phase" LEVEL="$level" \
|
|
3492
|
+
MESSAGE="$message" TIMESTAMP="$timestamp" \
|
|
3493
|
+
python3 - "$in_progress_file" <<'PY' 2>/dev/null || true
|
|
3494
|
+
import json, os, sys, tempfile
|
|
3495
|
+
path = sys.argv[1]
|
|
3496
|
+
target_id = f"iteration-{os.environ['ITER']}"
|
|
3497
|
+
entry = {
|
|
3498
|
+
"timestamp": os.environ["TIMESTAMP"],
|
|
3499
|
+
"iteration": int(os.environ["ITER"]),
|
|
3500
|
+
"level": os.environ.get("LEVEL", "info"),
|
|
3501
|
+
"phase": os.environ.get("PHASE", ""),
|
|
3502
|
+
"message": os.environ.get("MESSAGE", ""),
|
|
3503
|
+
}
|
|
3504
|
+
try:
|
|
3505
|
+
with open(path) as f:
|
|
3506
|
+
data = json.load(f)
|
|
3507
|
+
except Exception:
|
|
3508
|
+
sys.exit(0)
|
|
3509
|
+
# Support both [...] and {tasks: [...]} shapes (matches load_queue_tasks).
|
|
3510
|
+
tasks = data["tasks"] if isinstance(data, dict) and isinstance(data.get("tasks"), list) else (data if isinstance(data, list) else None)
|
|
3511
|
+
if tasks is None:
|
|
3512
|
+
sys.exit(0)
|
|
3513
|
+
mutated = False
|
|
3514
|
+
for t in tasks:
|
|
3515
|
+
if not isinstance(t, dict):
|
|
3516
|
+
continue
|
|
3517
|
+
if t.get("id") == target_id:
|
|
3518
|
+
logs = t.get("logs")
|
|
3519
|
+
if not isinstance(logs, list):
|
|
3520
|
+
logs = []
|
|
3521
|
+
logs.append(entry)
|
|
3522
|
+
t["logs"] = logs
|
|
3523
|
+
mutated = True
|
|
3524
|
+
break
|
|
3525
|
+
if not mutated:
|
|
3526
|
+
sys.exit(0)
|
|
3527
|
+
out_dir = os.path.dirname(path) or "."
|
|
3528
|
+
fd, tmp = tempfile.mkstemp(dir=out_dir, suffix=".json")
|
|
3529
|
+
with os.fdopen(fd, "w") as f:
|
|
3530
|
+
json.dump(data, f, indent=2)
|
|
3531
|
+
os.replace(tmp, path)
|
|
3532
|
+
PY
|
|
3456
3533
|
}
|
|
3457
3534
|
|
|
3458
3535
|
#===============================================================================
|
|
@@ -3758,18 +3835,34 @@ except: pass
|
|
|
3758
3835
|
task_json=$(python3 -c "
|
|
3759
3836
|
import json, sys
|
|
3760
3837
|
ctx = json.loads('''$next_task_context''')
|
|
3838
|
+
# v7.5.12: always emit acceptance_criteria, notes, logs so the dashboard
|
|
3839
|
+
# task model has consistent shape. default_ac covers the RARV gate-pass
|
|
3840
|
+
# requirements when no PRD-provided list exists.
|
|
3841
|
+
default_ac = [
|
|
3842
|
+
'REASON phase identifies next task without errors',
|
|
3843
|
+
'ACT phase produces verifiable artifacts (code/docs/tests)',
|
|
3844
|
+
'REFLECT phase records progress in CONTINUITY.md',
|
|
3845
|
+
'VERIFY phase passes automated tests / quality gates'
|
|
3846
|
+
]
|
|
3761
3847
|
task = {
|
|
3762
3848
|
'id': 'iteration-$iteration',
|
|
3763
3849
|
'type': 'iteration',
|
|
3764
3850
|
'title': ctx.get('current_task') or 'Iteration $iteration',
|
|
3765
|
-
'description': ctx.get('description') or 'PRD: ${prd_escaped}',
|
|
3851
|
+
'description': ctx.get('description') or 'RARV iteration $iteration. PRD: ${prd_escaped}',
|
|
3766
3852
|
'status': 'in_progress',
|
|
3767
3853
|
'priority': 'medium',
|
|
3768
3854
|
'startedAt': '$(date -u +%Y-%m-%dT%H:%M:%SZ)',
|
|
3769
|
-
'provider': '${PROVIDER_NAME:-claude}'
|
|
3855
|
+
'provider': '${PROVIDER_NAME:-claude}',
|
|
3856
|
+
'acceptance_criteria': ctx.get('acceptance_criteria') or default_ac,
|
|
3857
|
+
'notes': [],
|
|
3858
|
+
'logs': [{
|
|
3859
|
+
'timestamp': '$(date -u +%Y-%m-%dT%H:%M:%SZ)',
|
|
3860
|
+
'iteration': $iteration,
|
|
3861
|
+
'level': 'info',
|
|
3862
|
+
'phase': 'BOOTSTRAP',
|
|
3863
|
+
'message': 'Iteration $iteration started'
|
|
3864
|
+
}]
|
|
3770
3865
|
}
|
|
3771
|
-
if ctx.get('acceptance_criteria'):
|
|
3772
|
-
task['acceptance_criteria'] = ctx['acceptance_criteria']
|
|
3773
3866
|
if ctx.get('user_story'):
|
|
3774
3867
|
task['user_story'] = ctx['user_story']
|
|
3775
3868
|
if ctx.get('source'):
|
|
@@ -3782,27 +3875,49 @@ print(json.dumps(task, indent=2))
|
|
|
3782
3875
|
|
|
3783
3876
|
# Fallback to basic task JSON if enrichment failed
|
|
3784
3877
|
if [[ -z "${task_json:-}" ]]; then
|
|
3878
|
+
local _start_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
3785
3879
|
task_json=$(cat <<EOF
|
|
3786
3880
|
{
|
|
3787
3881
|
"id": "$task_id",
|
|
3788
3882
|
"type": "iteration",
|
|
3789
3883
|
"title": "Iteration $iteration",
|
|
3790
|
-
"description": "PRD: ${prd_escaped}",
|
|
3884
|
+
"description": "RARV iteration $iteration. PRD: ${prd_escaped}",
|
|
3791
3885
|
"status": "in_progress",
|
|
3792
3886
|
"priority": "medium",
|
|
3793
|
-
"startedAt": "$
|
|
3794
|
-
"provider": "${PROVIDER_NAME:-claude}"
|
|
3887
|
+
"startedAt": "$_start_ts",
|
|
3888
|
+
"provider": "${PROVIDER_NAME:-claude}",
|
|
3889
|
+
"acceptance_criteria": [
|
|
3890
|
+
"REASON phase identifies next task without errors",
|
|
3891
|
+
"ACT phase produces verifiable artifacts (code/docs/tests)",
|
|
3892
|
+
"REFLECT phase records progress in CONTINUITY.md",
|
|
3893
|
+
"VERIFY phase passes automated tests / quality gates"
|
|
3894
|
+
],
|
|
3895
|
+
"notes": [],
|
|
3896
|
+
"logs": [
|
|
3897
|
+
{
|
|
3898
|
+
"timestamp": "$_start_ts",
|
|
3899
|
+
"iteration": $iteration,
|
|
3900
|
+
"level": "info",
|
|
3901
|
+
"phase": "BOOTSTRAP",
|
|
3902
|
+
"message": "Iteration $iteration started"
|
|
3903
|
+
}
|
|
3904
|
+
]
|
|
3795
3905
|
}
|
|
3796
3906
|
EOF
|
|
3797
3907
|
)
|
|
3798
3908
|
fi
|
|
3799
3909
|
|
|
3800
3910
|
# Add to in-progress queue
|
|
3801
|
-
# BUG-XC-003:
|
|
3911
|
+
# BUG-XC-003: atomic queue modification.
|
|
3912
|
+
# v7.5.12: portable mkdir-mutex via safe_acquire_lock (no flock needed).
|
|
3913
|
+
# v7.5.12 Dev11 (R1 HIGH): gate the read-modify-write on acquire SUCCESS.
|
|
3914
|
+
# The prior `safe_acquire_lock ... || true` then unconditional
|
|
3915
|
+
# `safe_release_lock` mutated state on timeout AND released the OTHER
|
|
3916
|
+
# holder's lock -- a mutex correctness violation. Mirror the working
|
|
3917
|
+
# pattern at line 1845 (acquire-success guarded RMW + release inside).
|
|
3802
3918
|
local in_progress_file=".loki/queue/in-progress.json"
|
|
3803
3919
|
local lockfile=".loki/queue/.in-progress.lock"
|
|
3804
|
-
|
|
3805
|
-
flock -w 5 200 2>/dev/null || true
|
|
3920
|
+
if type safe_acquire_lock >/dev/null 2>&1 && safe_acquire_lock "$lockfile" 5; then
|
|
3806
3921
|
if [ -f "$in_progress_file" ]; then
|
|
3807
3922
|
local existing=$(cat "$in_progress_file")
|
|
3808
3923
|
if [ "$existing" = "[]" ] || [ -z "$existing" ]; then
|
|
@@ -3819,7 +3934,10 @@ print(json.dumps(data, indent=2))
|
|
|
3819
3934
|
else
|
|
3820
3935
|
echo "[$task_json]" > "$in_progress_file"
|
|
3821
3936
|
fi
|
|
3822
|
-
|
|
3937
|
+
safe_release_lock "$lockfile"
|
|
3938
|
+
else
|
|
3939
|
+
log_warn "could not acquire in-progress lock; skipping update"
|
|
3940
|
+
fi
|
|
3823
3941
|
|
|
3824
3942
|
# BUG-ST-014: Atomic current-task.json update via temp file + mv
|
|
3825
3943
|
local ct_tmp=".loki/queue/current-task.json.tmp.$$"
|
|
@@ -5562,11 +5680,75 @@ enforce_static_analysis() {
|
|
|
5562
5680
|
details="${details}ESLint: $(echo "$eslint_out" | tail -3 | tr '\n' ' '). "
|
|
5563
5681
|
}
|
|
5564
5682
|
else
|
|
5683
|
+
# v7.5.12 (Triage #2): when tsconfig.json exists, run
|
|
5684
|
+
# `tsc --noEmit -p .` ONCE so paths/baseUrl/types resolve.
|
|
5685
|
+
# Per-file `tsc` invocations ignore tsconfig and false-block on
|
|
5686
|
+
# path-aliased imports (e.g. `@/x`) in Next.js / NestJS /
|
|
5687
|
+
# monorepo projects. Only count errors that reference files
|
|
5688
|
+
# changed in this iteration; pre-existing errors in unchanged
|
|
5689
|
+
# files must not block.
|
|
5690
|
+
local _ts_project_mode=0
|
|
5691
|
+
if [ -f "${TARGET_DIR:-.}/tsconfig.json" ] && command -v tsc &>/dev/null; then
|
|
5692
|
+
local _has_ts=0
|
|
5693
|
+
for f in $abs_files; do
|
|
5694
|
+
case "$f" in *.ts|*.tsx) _has_ts=1; break ;; esac
|
|
5695
|
+
done
|
|
5696
|
+
if [ "$_has_ts" -eq 1 ]; then
|
|
5697
|
+
_ts_project_mode=1
|
|
5698
|
+
local _tsc_out _tsc_rc=0
|
|
5699
|
+
_tsc_out=$(cd "${TARGET_DIR:-.}" && tsc --noEmit -p . 2>&1) || _tsc_rc=$?
|
|
5700
|
+
if [ "$_tsc_rc" -ne 0 ]; then
|
|
5701
|
+
local _changed_ts_errors=""
|
|
5702
|
+
for f in $js_files; do
|
|
5703
|
+
case "$f" in
|
|
5704
|
+
*.ts|*.tsx)
|
|
5705
|
+
# tsc emits paths relative to project root with `(line,col):` suffix.
|
|
5706
|
+
# v7.5.12 Dev11 (R1 MED): use grep -F (literal) so filenames
|
|
5707
|
+
# containing regex metacharacters cannot cause false positives
|
|
5708
|
+
# or malformed regex. Two literal passes for the `(` and `:`
|
|
5709
|
+
# suffix forms tsc emits.
|
|
5710
|
+
if grep -qF -- "${f}(" <<<"$_tsc_out" || grep -qF -- "${f}:" <<<"$_tsc_out"; then
|
|
5711
|
+
_changed_ts_errors="${_changed_ts_errors}${f} "
|
|
5712
|
+
fi
|
|
5713
|
+
;;
|
|
5714
|
+
esac
|
|
5715
|
+
done
|
|
5716
|
+
if [ -n "$_changed_ts_errors" ]; then
|
|
5717
|
+
findings=$((findings + 1))
|
|
5718
|
+
details="${details}TS errors in changed files: ${_changed_ts_errors}. "
|
|
5719
|
+
else
|
|
5720
|
+
log_info "Static analysis: tsc -p . reported errors only in unchanged files (not blocking)"
|
|
5721
|
+
fi
|
|
5722
|
+
fi
|
|
5723
|
+
fi
|
|
5724
|
+
fi
|
|
5565
5725
|
for f in $abs_files; do
|
|
5566
|
-
node --check
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5726
|
+
# node --check cannot parse TypeScript / TSX files; it
|
|
5727
|
+
# crashes with ERR_UNKNOWN_FILE_EXTENSION. Skip them when
|
|
5728
|
+
# tsc is not available; otherwise delegate to tsc.
|
|
5729
|
+
case "$f" in
|
|
5730
|
+
*.ts|*.tsx)
|
|
5731
|
+
# When tsconfig project-mode handled it above, skip
|
|
5732
|
+
# the per-file fallback to avoid duplicate / false errors.
|
|
5733
|
+
if [ "$_ts_project_mode" -eq 1 ]; then
|
|
5734
|
+
continue
|
|
5735
|
+
fi
|
|
5736
|
+
if command -v tsc &>/dev/null; then
|
|
5737
|
+
tsc --noEmit --allowJs --jsx preserve --target esnext "$f" 2>&1 || {
|
|
5738
|
+
findings=$((findings + 1))
|
|
5739
|
+
details="${details}TS syntax error: $f. "
|
|
5740
|
+
}
|
|
5741
|
+
else
|
|
5742
|
+
log_info "Static analysis: skipping $f (tsc not on PATH; node --check cannot parse .ts/.tsx)"
|
|
5743
|
+
fi
|
|
5744
|
+
;;
|
|
5745
|
+
*)
|
|
5746
|
+
node --check "$f" 2>&1 || {
|
|
5747
|
+
findings=$((findings + 1))
|
|
5748
|
+
details="${details}Syntax error: $f. "
|
|
5749
|
+
}
|
|
5750
|
+
;;
|
|
5751
|
+
esac
|
|
5570
5752
|
done
|
|
5571
5753
|
fi
|
|
5572
5754
|
fi
|
|
@@ -5612,11 +5794,14 @@ enforce_static_analysis() {
|
|
|
5612
5794
|
}
|
|
5613
5795
|
done
|
|
5614
5796
|
if command -v shellcheck &>/dev/null; then
|
|
5797
|
+
# v7.5.12 (Triage #3): only `error` severity blocks. style/info/warning
|
|
5798
|
+
# findings on WIP shell scripts must not block iteration. `.shellcheckrc`
|
|
5799
|
+
# in the target dir is honored automatically by shellcheck (do not override).
|
|
5615
5800
|
for f in $sh_files; do
|
|
5616
5801
|
[ -f "${TARGET_DIR:-.}/$f" ] || continue
|
|
5617
|
-
shellcheck "${TARGET_DIR:-.}/$f" 2>&1 || {
|
|
5802
|
+
shellcheck -S error "${TARGET_DIR:-.}/$f" 2>&1 || {
|
|
5618
5803
|
findings=$((findings + 1))
|
|
5619
|
-
details="${details}shellcheck: $f. "
|
|
5804
|
+
details="${details}shellcheck (error severity): $f. "
|
|
5620
5805
|
}
|
|
5621
5806
|
done
|
|
5622
5807
|
fi
|
|
@@ -10593,6 +10778,8 @@ except Exception as exc:
|
|
|
10593
10778
|
|
|
10594
10779
|
# Provider-specific invocation with dynamic tier selection
|
|
10595
10780
|
local exit_code=0
|
|
10781
|
+
# v7.5.12: Mark provider pipeline as active so SIGINT trap can kill it.
|
|
10782
|
+
LOKI_PROVIDER_ACTIVE=1
|
|
10596
10783
|
case "${PROVIDER_NAME:-claude}" in
|
|
10597
10784
|
claude)
|
|
10598
10785
|
# Claude: Full features with stream-json output and agent tracking
|
|
@@ -10917,6 +11104,8 @@ if __name__ == "__main__":
|
|
|
10917
11104
|
local exit_code=1
|
|
10918
11105
|
;;
|
|
10919
11106
|
esac
|
|
11107
|
+
# v7.5.12: Provider invocation finished (or was killed by trap).
|
|
11108
|
+
LOKI_PROVIDER_ACTIVE=0
|
|
10920
11109
|
|
|
10921
11110
|
echo ""
|
|
10922
11111
|
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
@@ -10930,6 +11119,25 @@ if __name__ == "__main__":
|
|
|
10930
11119
|
|
|
10931
11120
|
log_info "${PROVIDER_DISPLAY_NAME:-Claude} exited with code $exit_code after ${duration}s"
|
|
10932
11121
|
|
|
11122
|
+
# v7.5.12 Gap A: Distinguish signal-induced exits (130/143/137) from clean failure.
|
|
11123
|
+
# Without this, post-iteration logic may quietly proceed past a SIGINT/SIGTERM,
|
|
11124
|
+
# leaving stale state and confusing the next iteration. Any non-zero exit is a
|
|
11125
|
+
# failure, but signal exits warrant a louder log line for forensic clarity.
|
|
11126
|
+
case "$exit_code" in
|
|
11127
|
+
130)
|
|
11128
|
+
log_warn "Provider terminated by SIGINT (exit 130) -- treating as user interrupt"
|
|
11129
|
+
emit_event_pending "provider_interrupted" "signal=SIGINT" "exit_code=130" 2>/dev/null || true
|
|
11130
|
+
;;
|
|
11131
|
+
143)
|
|
11132
|
+
log_warn "Provider terminated by SIGTERM (exit 143) -- treating as forced shutdown"
|
|
11133
|
+
emit_event_pending "provider_interrupted" "signal=SIGTERM" "exit_code=143" 2>/dev/null || true
|
|
11134
|
+
;;
|
|
11135
|
+
137)
|
|
11136
|
+
log_warn "Provider killed by SIGKILL (exit 137) -- treating as forced shutdown"
|
|
11137
|
+
emit_event_pending "provider_interrupted" "signal=SIGKILL" "exit_code=137" 2>/dev/null || true
|
|
11138
|
+
;;
|
|
11139
|
+
esac
|
|
11140
|
+
|
|
10933
11141
|
# BUG-EC-013: Detect empty provider output (0 bytes = no work done)
|
|
10934
11142
|
if [ -f "$iter_output" ] && [ ! -s "$iter_output" ] && [ $exit_code -eq 0 ]; then
|
|
10935
11143
|
log_warn "Provider returned empty output (0 bytes) despite exit code 0 -- treating as error"
|
|
@@ -11310,6 +11518,50 @@ INTERRUPT_COUNT=0
|
|
|
11310
11518
|
INTERRUPT_LAST_TIME=0
|
|
11311
11519
|
PAUSED=false
|
|
11312
11520
|
|
|
11521
|
+
# v7.5.12: Track active provider invocation for SIGINT propagation.
|
|
11522
|
+
# When non-zero, indicates a provider pipeline (claude/codex/gemini/cline/aider)
|
|
11523
|
+
# is currently running and should be killed on Ctrl+C.
|
|
11524
|
+
LOKI_PROVIDER_ACTIVE=0
|
|
11525
|
+
|
|
11526
|
+
# v7.5.12: Kill provider pipeline children with SIGTERM, then SIGKILL escalation.
|
|
11527
|
+
# Uses pkill -P $$ to target direct children only (the pipeline subshells).
|
|
11528
|
+
# Returns 0 if anything was killed, 1 if no children present.
|
|
11529
|
+
kill_provider_child() {
|
|
11530
|
+
local killed=0
|
|
11531
|
+
# First pass: SIGTERM to direct children of this shell. Pipeline subshells
|
|
11532
|
+
# for `claude -p | tee | python` are direct children of $$.
|
|
11533
|
+
if pkill -TERM -P $$ 2>/dev/null; then
|
|
11534
|
+
killed=1
|
|
11535
|
+
fi
|
|
11536
|
+
# Also kill provider leaf processes by name in case they were reparented.
|
|
11537
|
+
local proc
|
|
11538
|
+
for proc in claude codex gemini aider cline; do
|
|
11539
|
+
pkill -TERM -f "^${proc}( |$)" 2>/dev/null && killed=1
|
|
11540
|
+
done
|
|
11541
|
+
|
|
11542
|
+
# Brief wait for graceful exit (max ~2s).
|
|
11543
|
+
local i=0
|
|
11544
|
+
while [ $i -lt 20 ]; do
|
|
11545
|
+
if ! pgrep -P $$ >/dev/null 2>&1; then
|
|
11546
|
+
break
|
|
11547
|
+
fi
|
|
11548
|
+
sleep 0.1
|
|
11549
|
+
i=$((i + 1))
|
|
11550
|
+
done
|
|
11551
|
+
|
|
11552
|
+
# Escalate to SIGKILL for any survivors.
|
|
11553
|
+
if pgrep -P $$ >/dev/null 2>&1; then
|
|
11554
|
+
pkill -KILL -P $$ 2>/dev/null || true
|
|
11555
|
+
killed=1
|
|
11556
|
+
fi
|
|
11557
|
+
|
|
11558
|
+
LOKI_PROVIDER_ACTIVE=0
|
|
11559
|
+
if [ $killed -eq 1 ]; then
|
|
11560
|
+
return 0
|
|
11561
|
+
fi
|
|
11562
|
+
return 1
|
|
11563
|
+
}
|
|
11564
|
+
|
|
11313
11565
|
# Check for human intervention signals
|
|
11314
11566
|
check_human_intervention() {
|
|
11315
11567
|
local loki_dir="${TARGET_DIR:-.}/.loki"
|
|
@@ -11544,7 +11796,9 @@ cleanup() {
|
|
|
11544
11796
|
# Exit immediately without entering interactive pause mode
|
|
11545
11797
|
if [ -f "$loki_dir/STOP" ]; then
|
|
11546
11798
|
echo ""
|
|
11547
|
-
log_warn "
|
|
11799
|
+
log_warn "Loki Mode interrupted -- shutting down (STOP signal)"
|
|
11800
|
+
# v7.5.12: Kill any running provider pipeline first, before slow cleanup.
|
|
11801
|
+
kill_provider_child 2>/dev/null || true
|
|
11548
11802
|
rm -f "$loki_dir/STOP" "$loki_dir/PAUSE" "$loki_dir/PAUSED.md" 2>/dev/null
|
|
11549
11803
|
if type app_runner_cleanup &>/dev/null; then
|
|
11550
11804
|
app_runner_cleanup
|
|
@@ -11582,7 +11836,11 @@ except (json.JSONDecodeError, OSError): pass
|
|
|
11582
11836
|
# If double Ctrl+C within 2 seconds, exit immediately
|
|
11583
11837
|
if [ "$time_diff" -lt 2 ] && [ "$INTERRUPT_COUNT" -gt 0 ]; then
|
|
11584
11838
|
echo ""
|
|
11585
|
-
log_warn "
|
|
11839
|
+
log_warn "Loki Mode interrupted -- shutting down (double Ctrl+C)"
|
|
11840
|
+
# v7.5.12: Kill provider pipeline immediately so we don't wait on it.
|
|
11841
|
+
kill_provider_child 2>/dev/null || true
|
|
11842
|
+
# Write STOP signal so any peer processes (dashboard, etc.) also stop.
|
|
11843
|
+
mkdir -p "$loki_dir" 2>/dev/null && touch "$loki_dir/STOP" 2>/dev/null || true
|
|
11586
11844
|
if type app_runner_cleanup &>/dev/null; then
|
|
11587
11845
|
app_runner_cleanup
|
|
11588
11846
|
fi
|
|
@@ -11630,13 +11888,20 @@ except (json.JSONDecodeError, OSError): pass
|
|
|
11630
11888
|
fi
|
|
11631
11889
|
|
|
11632
11890
|
# In perpetual/autonomous mode: NEVER pause, NEVER wait for input
|
|
11633
|
-
#
|
|
11891
|
+
# v7.5.12: A single Ctrl+C now interrupts the *current provider invocation*
|
|
11892
|
+
# (so the user can abort a hung iteration) but lets the loop continue.
|
|
11893
|
+
# A second Ctrl+C within 2s exits via the double-interrupt branch above.
|
|
11634
11894
|
if [ "$AUTONOMY_MODE" = "perpetual" ] || [ "$PERPETUAL_MODE" = "true" ]; then
|
|
11635
11895
|
INTERRUPT_COUNT=$((INTERRUPT_COUNT + 1))
|
|
11636
11896
|
INTERRUPT_LAST_TIME=$current_time
|
|
11637
11897
|
echo ""
|
|
11638
|
-
|
|
11639
|
-
|
|
11898
|
+
if [ "$LOKI_PROVIDER_ACTIVE" -eq 1 ]; then
|
|
11899
|
+
log_warn "Interrupt received -- killing current provider invocation"
|
|
11900
|
+
kill_provider_child 2>/dev/null || true
|
|
11901
|
+
else
|
|
11902
|
+
log_warn "Interrupt received in perpetual mode -- iteration will continue"
|
|
11903
|
+
fi
|
|
11904
|
+
log_info "Press Ctrl+C again within 2 seconds to exit, or touch .loki/STOP"
|
|
11640
11905
|
echo ""
|
|
11641
11906
|
# Check and restart dashboard if it died
|
|
11642
11907
|
handle_dashboard_crash
|
|
@@ -11920,17 +12185,13 @@ main() {
|
|
|
11920
12185
|
lock_file=".loki/session.lock"
|
|
11921
12186
|
fi
|
|
11922
12187
|
|
|
11923
|
-
#
|
|
11924
|
-
|
|
11925
|
-
|
|
11926
|
-
|
|
11927
|
-
|
|
11928
|
-
|
|
11929
|
-
|
|
11930
|
-
exec 200>"$lock_file"
|
|
11931
|
-
|
|
11932
|
-
# Try to acquire exclusive lock (non-blocking)
|
|
11933
|
-
if ! flock -n 200 2>/dev/null; then
|
|
12188
|
+
# Atomic session lock via mkdir-mutex (v7.5.12). Replaces flock-only
|
|
12189
|
+
# path that emitted "[WARN] flock not available ..." on macOS. The
|
|
12190
|
+
# mkdir-based lock is portable, atomic on POSIX, and self-heals via
|
|
12191
|
+
# PID-stamped sentinel + 30s mtime-based stale reaping.
|
|
12192
|
+
touch "$lock_file" 2>/dev/null || true
|
|
12193
|
+
if type safe_acquire_lock >/dev/null 2>&1; then
|
|
12194
|
+
if ! safe_acquire_lock "$lock_file" 5; then
|
|
11934
12195
|
if [ -n "${LOKI_SESSION_ID:-}" ]; then
|
|
11935
12196
|
log_error "Session '${LOKI_SESSION_ID}' is already running (locked)"
|
|
11936
12197
|
log_error "Stop it first with: loki stop ${LOKI_SESSION_ID}"
|
|
@@ -11940,6 +12201,10 @@ main() {
|
|
|
11940
12201
|
fi
|
|
11941
12202
|
exit 1
|
|
11942
12203
|
fi
|
|
12204
|
+
# Release on session-process exit so a fresh `loki start` can
|
|
12205
|
+
# immediately re-acquire after this one finishes / is killed.
|
|
12206
|
+
# shellcheck disable=SC2064
|
|
12207
|
+
trap "safe_release_lock '$lock_file'" EXIT INT TERM HUP
|
|
11943
12208
|
|
|
11944
12209
|
# Check PID file after acquiring lock
|
|
11945
12210
|
if [ -f "$pid_file" ]; then
|
|
@@ -11958,12 +12223,10 @@ main() {
|
|
|
11958
12223
|
fi
|
|
11959
12224
|
fi
|
|
11960
12225
|
else
|
|
11961
|
-
#
|
|
11962
|
-
log_warn "flock not available - using non-atomic PID check (race condition possible)"
|
|
12226
|
+
# Lock helper not loaded (lib/lock.sh missing). PID-only fallback.
|
|
11963
12227
|
if [ -f "$pid_file" ]; then
|
|
11964
12228
|
local existing_pid
|
|
11965
12229
|
existing_pid=$(cat "$pid_file" 2>/dev/null)
|
|
11966
|
-
# Skip if it's our own PID or parent PID (background mode writes PID before child starts)
|
|
11967
12230
|
if [ -n "$existing_pid" ] && [ "$existing_pid" != "$$" ] && [ "$existing_pid" != "$PPID" ] && kill -0 "$existing_pid" 2>/dev/null; then
|
|
11968
12231
|
if [ -n "${LOKI_SESSION_ID:-}" ]; then
|
|
11969
12232
|
log_error "Session '${LOKI_SESSION_ID}' is already running (PID: $existing_pid)"
|
package/dashboard/__init__.py
CHANGED
package/dashboard/database.py
CHANGED
|
@@ -47,12 +47,38 @@ async def init_db() -> None:
|
|
|
47
47
|
try:
|
|
48
48
|
async with engine.begin() as conn:
|
|
49
49
|
await conn.run_sync(Base.metadata.create_all)
|
|
50
|
+
# v7.5.12: Idempotent column adds for legacy SQLite databases.
|
|
51
|
+
# New installs get the columns from create_all; existing installs
|
|
52
|
+
# need ALTER TABLE because we have no migration framework.
|
|
53
|
+
await conn.run_sync(_apply_task_enrichment_migration)
|
|
50
54
|
logger.info("Database initialized at %s", DATABASE_PATH)
|
|
51
55
|
except Exception as exc:
|
|
52
56
|
logger.error("Database initialization failed: %s", exc, exc_info=True)
|
|
53
57
|
raise
|
|
54
58
|
|
|
55
59
|
|
|
60
|
+
def _apply_task_enrichment_migration(sync_conn) -> None:
|
|
61
|
+
"""Add v7.5.12 task enrichment columns if they don't exist.
|
|
62
|
+
|
|
63
|
+
SQLite-specific: PRAGMA table_info to inspect, ALTER TABLE ADD COLUMN
|
|
64
|
+
to extend. Safe to run repeatedly. No-op on a fresh DB where columns
|
|
65
|
+
were already created by Base.metadata.create_all.
|
|
66
|
+
"""
|
|
67
|
+
from sqlalchemy import text as _text
|
|
68
|
+
try:
|
|
69
|
+
rows = sync_conn.execute(_text("PRAGMA table_info(tasks)")).fetchall()
|
|
70
|
+
except Exception:
|
|
71
|
+
return
|
|
72
|
+
existing_cols = {row[1] for row in rows}
|
|
73
|
+
for col in ("acceptance_criteria", "notes", "logs"):
|
|
74
|
+
if col not in existing_cols:
|
|
75
|
+
try:
|
|
76
|
+
sync_conn.execute(_text(f"ALTER TABLE tasks ADD COLUMN {col} TEXT"))
|
|
77
|
+
logger.info("Added column tasks.%s (v7.5.12 enrichment)", col)
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
logger.warning("Could not add column tasks.%s: %s", col, exc)
|
|
80
|
+
|
|
81
|
+
|
|
56
82
|
async def close_db() -> None:
|
|
57
83
|
"""Close database connections."""
|
|
58
84
|
await engine.dispose()
|
package/dashboard/models.py
CHANGED
|
@@ -142,6 +142,14 @@ class Task(Base):
|
|
|
142
142
|
)
|
|
143
143
|
estimated_duration: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
144
144
|
actual_duration: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
145
|
+
# v7.5.12: Enriched task detail fields (additive, JSON-encoded text).
|
|
146
|
+
# acceptance_criteria: JSON list of strings.
|
|
147
|
+
# notes: JSON list of {timestamp, author, body}.
|
|
148
|
+
# logs: JSON list of {timestamp, iteration, level, phase, message}.
|
|
149
|
+
# All are nullable; legacy rows render as empty lists in TaskResponse.
|
|
150
|
+
acceptance_criteria: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
151
|
+
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
152
|
+
logs: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
145
153
|
created_at: Mapped[datetime] = mapped_column(
|
|
146
154
|
DateTime, server_default=func.now(), nullable=False
|
|
147
155
|
)
|