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/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
- # and use flock for queue locking
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
- ) 200>"$lockfile"
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
- # Use flock if available to avoid TOCTOU race
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: check only this session's lock
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
- if command -v flock >/dev/null 2>&1 && [ -f "$lock_file" ]; then
3035
- { if flock -n 201 2>/dev/null; then can_cleanup=true; fi } 201>"$lock_file"
3036
- else
3037
- local existing_pid=""
3038
- if [ -f "$session_pid_file" ]; then
3039
- existing_pid=$(cat "$session_pid_file" 2>/dev/null)
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: original behavior
3063
+ # Global: PID-liveness probe
3051
3064
  lock_file=".loki/session.lock"
3052
- if command -v flock >/dev/null 2>&1 && [ -f "$lock_file" ]; then
3053
- { if flock -n 201 2>/dev/null; then can_cleanup=true; fi } 201>"$lock_file"
3054
- else
3055
- local existing_pid=""
3056
- if [ -f ".loki/loki.pid" ]; then
3057
- existing_pid=$(cat ".loki/loki.pid" 2>/dev/null)
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": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
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: Use flock for atomic queue modification
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
- ) 200>"$lockfile"
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 "$f" 2>&1 || {
5567
- findings=$((findings + 1))
5568
- details="${details}Syntax error: $f. "
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 "Stop signal received - shutting down"
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 "Double interrupt - stopping immediately"
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
- # Log the interrupt but continue the iteration loop immediately
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
- log_warn "Interrupt received in perpetual mode - ignoring (not pausing)"
11639
- log_info "To stop: touch .loki/STOP or press Ctrl+C twice within 2 seconds"
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
- # Use flock for atomic locking to prevent TOCTOU race conditions
11924
- if command -v flock >/dev/null 2>&1; then
11925
- # Create lock file
11926
- touch "$lock_file"
11927
-
11928
- # Open FD 200 at process scope so flock persists for entire session lifetime
11929
- # (block-scoped redirection would release the lock when the block exits)
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
- # Fallback to original behavior if flock not available
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)"
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.5.10"
10
+ __version__ = "7.5.12"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -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()
@@ -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
  )