loki-mode 7.89.1 → 7.90.1

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
@@ -572,6 +572,25 @@ if [ -f "$GIT_PR_ADVISORY_LIB" ]; then
572
572
  source "$GIT_PR_ADVISORY_LIB"
573
573
  fi
574
574
 
575
+ # Proven PR (Loop 6): shared print-only Evidence Receipt renderer for PR bodies.
576
+ # render_evidence_receipt_md prints the run's honest headline + facts +
577
+ # verify-yourself block into the PR body. Pure, never pushes/PRs/mutates.
578
+ PROOF_PR_LIB="$SCRIPT_DIR/lib/proof-pr.sh"
579
+ if [ -f "$PROOF_PR_LIB" ]; then
580
+ # shellcheck source=lib/proof-pr.sh
581
+ source "$PROOF_PR_LIB"
582
+ fi
583
+
584
+ # Proven PR (Loop 6 / Slice B): optional advisory verified-completion check-run.
585
+ # Owned by Slice B (autonomy/lib/proof-check.sh); sourced guarded so this slice
586
+ # is correct whether or not the file is present in the tree, and the single call
587
+ # site is itself guarded on declare -f + LOKI_PROVEN_PR_CHECK.
588
+ PROOF_CHECK_LIB="$SCRIPT_DIR/lib/proof-check.sh"
589
+ if [ -f "$PROOF_CHECK_LIB" ]; then
590
+ # shellcheck source=lib/proof-check.sh
591
+ source "$PROOF_CHECK_LIB"
592
+ fi
593
+
575
594
  # Completion Council (v5.25.0) - Multi-agent completion verification
576
595
  # Source completion council module
577
596
  COUNCIL_SCRIPT="$SCRIPT_DIR/completion-council.sh"
@@ -2330,6 +2349,19 @@ EOF
2330
2349
  jq -r '.completed_tasks[]? | select(.github_issue) | "Closes #\(.github_issue)"' .loki/ledger.json >> "$pr_body" 2>/dev/null || true
2331
2350
  fi
2332
2351
 
2352
+ # Proven PR (Loop 6): append the Evidence Receipt to the body file before
2353
+ # gh pr create --body-file. Default-on; LOKI_PROVEN_PR=0 -> body file bytes
2354
+ # byte-identical to before. Empty expected_head_sha by design (R-DET-1
2355
+ # run_id pointer is the anti-stale guard; the branch head is offset by the
2356
+ # session commit). Best-effort: a missing proof appends nothing extra here.
2357
+ if [ "${LOKI_PROVEN_PR:-1}" != "0" ] && declare -f render_evidence_receipt_md >/dev/null 2>&1; then
2358
+ local _bf_proof=""
2359
+ _bf_proof="$(_loki_proof_json_for_pr 2>/dev/null || true)"
2360
+ if [ -n "$_bf_proof" ]; then
2361
+ { printf '\n'; render_evidence_receipt_md "$_bf_proof" "" "" 2>/dev/null; } >> "$pr_body" 2>/dev/null || true
2362
+ fi
2363
+ fi
2364
+
2333
2365
  # Build PR create command
2334
2366
  local pr_args=("pr" "create" "--repo" "$repo" "--title" "[Loki Mode] $feature_name" "--body-file" "$pr_body")
2335
2367
 
@@ -3184,7 +3216,30 @@ on_run_complete() {
3184
3216
  log_info "LOKI_DELEGATE_PR=1: PR already exists for branch '$branch': $existing_pr (skipping create)."
3185
3217
  return 0
3186
3218
  fi
3187
- pr_url="$( (cd "${TARGET_DIR:-.}" && _loki_net gh pr create --title "$pr_title" --body "Opened by Loki Mode (delegate mode). Review locally before merge." --head "$branch") 2>/dev/null || true )"
3219
+ # Proven PR (Loop 6): append the Evidence Receipt to the inline body.
3220
+ # Default-on; LOKI_PROVEN_PR=0 -> body byte-identical to before. This path
3221
+ # runs gh from a `cd "${TARGET_DIR:-.}"` subshell, so resolve the proof
3222
+ # relative to TARGET_DIR (not the bare-relative helper). Empty
3223
+ # expected_head_sha by design (R-DET-1 run_id pointer is the anti-stale guard).
3224
+ local _del_body="Opened by Loki Mode (delegate mode). Review locally before merge."
3225
+ if [ "${LOKI_PROVEN_PR:-1}" != "0" ] && declare -f render_evidence_receipt_md >/dev/null 2>&1; then
3226
+ local _del_loki="${TARGET_DIR:-.}/.loki"
3227
+ local _del_idfile="$_del_loki/state/last-proof-id.txt"
3228
+ if [ -s "$_del_idfile" ]; then
3229
+ local _del_rid=""
3230
+ _del_rid="$(cat "$_del_idfile" 2>/dev/null || true)"
3231
+ if [ -n "$_del_rid" ] && [ -f "$_del_loki/proofs/$_del_rid/proof.json" ]; then
3232
+ local _del_receipt=""
3233
+ _del_receipt="$(render_evidence_receipt_md "$_del_loki/proofs/$_del_rid/proof.json" "" "" 2>/dev/null || true)"
3234
+ if [ -n "$_del_receipt" ]; then
3235
+ _del_body="${_del_body}
3236
+
3237
+ ${_del_receipt}"
3238
+ fi
3239
+ fi
3240
+ fi
3241
+ fi
3242
+ pr_url="$( (cd "${TARGET_DIR:-.}" && _loki_net gh pr create --title "$pr_title" --body "$_del_body" --head "$branch") 2>/dev/null || true )"
3188
3243
  if [ -n "$pr_url" ]; then
3189
3244
  # Export so build_completion_summary folds the url into the summary.
3190
3245
  _LOKI_DELEGATE_PR_URL="$pr_url"
@@ -5501,6 +5556,46 @@ generate_proof_of_run() {
5501
5556
  local ver provider
5502
5557
  ver="$(get_version 2>/dev/null || echo unknown)"
5503
5558
  provider="${PROVIDER_NAME:-claude}"
5559
+
5560
+ # Proven PR (Loop 6 / Slice A2): resolve a deterministic run_id so the
5561
+ # proof + the PR Evidence Receipt agree, and persist a stable pointer the PR
5562
+ # sites read (never newest-by-mtime). The generator otherwise mints a fresh
5563
+ # _gen_run_id() when LOKI_SESSION_ID is unset (the `loki start ./prd.md`
5564
+ # case), which the later PR step could not know. Gated on the SAME flag as the
5565
+ # receipt so LOKI_PROVEN_PR=0 is a byte-identical no-op (no --run-id passed,
5566
+ # no pointer written): the pointer is only ever READ by the renderer, which
5567
+ # is itself off under that flag.
5568
+ if [ "${LOKI_PROVEN_PR:-1}" != "0" ]; then
5569
+ local _rid=""
5570
+ if declare -f _loki_trust_run_id >/dev/null 2>&1; then
5571
+ _rid="$(_loki_trust_run_id 2>/dev/null || true)"
5572
+ fi
5573
+ # Fall back to a locally minted id when no persisted per-run id exists.
5574
+ # Do NOT call _loki_trust_run_id --new here: that would clobber the
5575
+ # trust-events run-id file; this is a read-or-mint-local resolution.
5576
+ if [ -z "$_rid" ]; then
5577
+ _rid="proof-$(date -u +%Y%m%d%H%M%S 2>/dev/null || echo 0)-$$-${RANDOM:-0}"
5578
+ fi
5579
+ ITERATION_COUNT="${ITERATION_COUNT:-0}" \
5580
+ PROVIDER_NAME="$provider" \
5581
+ PRD_PATH="${prd_path:-}" \
5582
+ python3 "$gen" \
5583
+ --loki-dir "$loki_dir" \
5584
+ --loki-version "$ver" \
5585
+ --provider "$provider" \
5586
+ --run-id "$_rid" \
5587
+ --quiet >/dev/null 2>&1 || true
5588
+ # Persist the resolved run_id atomically (.tmp + mv) so the PR sites read
5589
+ # the exact run the generator wrote, never an mtime guess.
5590
+ local _id_dir="$loki_dir/state"
5591
+ local _id_file="$_id_dir/last-proof-id.txt"
5592
+ mkdir -p "$_id_dir" 2>/dev/null || true
5593
+ if printf '%s' "$_rid" > "${_id_file}.tmp" 2>/dev/null; then
5594
+ mv -f "${_id_file}.tmp" "$_id_file" 2>/dev/null || rm -f "${_id_file}.tmp" 2>/dev/null || true
5595
+ fi
5596
+ return 0
5597
+ fi
5598
+
5504
5599
  ITERATION_COUNT="${ITERATION_COUNT:-0}" \
5505
5600
  PROVIDER_NAME="$provider" \
5506
5601
  PRD_PATH="${prd_path:-}" \
@@ -6327,6 +6422,25 @@ commit_session_changes() {
6327
6422
  return 0
6328
6423
  }
6329
6424
 
6425
+ # _loki_proof_json_for_pr
6426
+ # Resolve THIS run's proof.json path from the persisted run_id pointer
6427
+ # (.loki/state/last-proof-id.txt, written by generate_proof_of_run / Slice A2).
6428
+ # Echoes the path when both the pointer and the file exist, else empty. NEVER
6429
+ # uses newest-by-mtime (R-DET-1). Best-effort, always returns 0. Uses the bare
6430
+ # relative .loki to match the other create_session_pr state reads (cwd==TARGET_DIR
6431
+ # at PR time). Returns empty under LOKI_PROVEN_PR=0 (pointer is never written).
6432
+ _loki_proof_json_for_pr() {
6433
+ local id_file=".loki/state/last-proof-id.txt"
6434
+ [ -s "$id_file" ] || { printf '%s' ""; return 0; }
6435
+ local rid=""
6436
+ rid="$(cat "$id_file" 2>/dev/null || true)"
6437
+ [ -n "$rid" ] || { printf '%s' ""; return 0; }
6438
+ local p=".loki/proofs/$rid/proof.json"
6439
+ [ -f "$p" ] || { printf '%s' ""; return 0; }
6440
+ printf '%s' "$p"
6441
+ return 0
6442
+ }
6443
+
6330
6444
  create_session_pr() {
6331
6445
  # Advise the user how to open a PR for the agent branch. PRINT-ONLY by
6332
6446
  # default (no push, no PR). LOKI_AUTO_PR=1 restores the legacy auto behavior.
@@ -6371,6 +6485,22 @@ create_session_pr() {
6371
6485
  else
6372
6486
  log_info "To open a pull request: git push -u origin ${branch_name}, then open a PR (base: ${base})"
6373
6487
  fi
6488
+ # Proven PR (Loop 6): print the Evidence Receipt block AFTER the push/PR
6489
+ # advice so a user opening a manual PR can paste it into the body. This is
6490
+ # the print-PR-body fallback. Default-on; LOKI_PROVEN_PR=0 -> not invoked
6491
+ # (advisory output byte-identical to before). Production callers pass an
6492
+ # empty expected_head_sha: the session commit lands between proof-gen and
6493
+ # this point, so the branch head is structurally offset from the proof's
6494
+ # head and feeding it would false-degrade every legitimate receipt; the
6495
+ # anti-stale guarantee is the run_id pointer (R-DET-1), not a head match.
6496
+ if [ "${LOKI_PROVEN_PR:-1}" != "0" ] && declare -f render_evidence_receipt_md >/dev/null 2>&1; then
6497
+ local _pr_proof=""
6498
+ _pr_proof="$(_loki_proof_json_for_pr 2>/dev/null || true)"
6499
+ if [ -n "$_pr_proof" ]; then
6500
+ printf '\n'
6501
+ render_evidence_receipt_md "$_pr_proof" "" "" || true
6502
+ fi
6503
+ fi
6374
6504
  return 0
6375
6505
  fi
6376
6506
 
@@ -6395,21 +6525,95 @@ create_session_pr() {
6395
6525
  if [ -n "$existing_pr" ]; then
6396
6526
  log_info "PR already exists for branch $branch_name: $existing_pr (skipping create)"
6397
6527
  audit_log "PR_EXISTS" "branch=$branch_name,url=$existing_pr"
6528
+ # Proven PR (Loop 6, PO-locked Q1): on idempotent reuse, leave the
6529
+ # existing PR untouched (do not rewrite the body, edit the PR, or
6530
+ # post a comment). Just hint that an Evidence Receipt is available.
6531
+ if [ "${LOKI_PROVEN_PR:-1}" != "0" ]; then
6532
+ local _exist_proof=""
6533
+ _exist_proof="$(_loki_proof_json_for_pr 2>/dev/null || true)"
6534
+ if [ -n "$_exist_proof" ]; then
6535
+ log_info "Evidence Receipt available for this run: loki proof open (run id in .loki/state/last-proof-id.txt)"
6536
+ fi
6537
+ fi
6398
6538
  return 0
6399
6539
  fi
6400
- pr_url=$(gh pr create \
6401
- --title "Loki Mode: Agent session changes ($branch_name)" \
6402
- --body "Automated changes from Loki Mode agent session.
6540
+ # Build the body into a variable so the Proven PR Evidence Receipt can be
6541
+ # appended (Loop 6). Default-on; LOKI_PROVEN_PR=0 -> body bytes are
6542
+ # byte-identical to the legacy inline body. Empty expected_head_sha by
6543
+ # design (see the advisory-branch note: the session commit offsets the
6544
+ # branch head from the proof head; R-DET-1 run_id pointer is the guard).
6545
+ local _auto_body
6546
+ _auto_body="Automated changes from Loki Mode agent session.
6403
6547
 
6404
6548
  Branch: \`$branch_name\`
6405
6549
  Session PID: $$
6406
- Created: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
6550
+ Created: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
6551
+ local _auto_proof=""
6552
+ if [ "${LOKI_PROVEN_PR:-1}" != "0" ] && declare -f render_evidence_receipt_md >/dev/null 2>&1; then
6553
+ _auto_proof="$(_loki_proof_json_for_pr 2>/dev/null || true)"
6554
+ if [ -n "$_auto_proof" ]; then
6555
+ local _auto_receipt=""
6556
+ _auto_receipt="$(render_evidence_receipt_md "$_auto_proof" "" "" 2>/dev/null || true)"
6557
+ if [ -n "$_auto_receipt" ]; then
6558
+ _auto_body="${_auto_body}
6559
+
6560
+ ${_auto_receipt}"
6561
+ fi
6562
+ fi
6563
+ fi
6564
+ pr_url=$(gh pr create \
6565
+ --title "Loki Mode: Agent session changes ($branch_name)" \
6566
+ --body "$_auto_body" \
6407
6567
  --base "$base" \
6408
6568
  --head "$branch_name" 2>/dev/null) || true
6409
6569
 
6410
6570
  if [ -n "$pr_url" ]; then
6411
6571
  log_info "PR created: $pr_url"
6412
6572
  audit_log "PR_CREATED" "branch=$branch_name,url=$pr_url"
6573
+ # Proven PR (Loop 6 / Slice D linkage): persist {run_id, pr_url} next
6574
+ # to the proof so the dashboard proofs panel can show "PR #N:
6575
+ # <headline>". Slice D owns the READ; Slice A owns this WRITE. Atomic
6576
+ # .tmp + mv, python3-guarded for correct JSON escaping, only when a
6577
+ # proof for THIS run exists and a pr_url was returned. Best-effort.
6578
+ if [ -n "$_auto_proof" ] && command -v python3 >/dev/null 2>&1; then
6579
+ local _pr_json_dir
6580
+ _pr_json_dir="$(dirname "$_auto_proof" 2>/dev/null || true)"
6581
+ if [ -n "$_pr_json_dir" ] && [ -d "$_pr_json_dir" ]; then
6582
+ local _pr_run_id
6583
+ _pr_run_id="$(basename "$_pr_json_dir" 2>/dev/null || true)"
6584
+ LOKI_PR_JSON_DIR="$_pr_json_dir" \
6585
+ LOKI_PR_RUN_ID="$_pr_run_id" \
6586
+ LOKI_PR_URL="$pr_url" \
6587
+ python3 - <<'PR_JSON_PY' 2>/dev/null || true
6588
+ import json
6589
+ import os
6590
+
6591
+ d = os.environ.get("LOKI_PR_JSON_DIR", "")
6592
+ run_id = os.environ.get("LOKI_PR_RUN_ID", "")
6593
+ pr_url = os.environ.get("LOKI_PR_URL", "")
6594
+ if d and os.path.isdir(d):
6595
+ path = os.path.join(d, "pr.json")
6596
+ tmp = path + ".tmp"
6597
+ try:
6598
+ with open(tmp, "w") as f:
6599
+ json.dump({"run_id": run_id, "pr_url": pr_url}, f)
6600
+ os.replace(tmp, path)
6601
+ except Exception:
6602
+ try:
6603
+ os.remove(tmp)
6604
+ except Exception:
6605
+ pass
6606
+ PR_JSON_PY
6607
+ fi
6608
+ fi
6609
+ # Proven PR (Loop 6 / Slice B integration): one guarded call to the
6610
+ # optional advisory verified-completion check-run. Posts NOTHING by
6611
+ # default (LOKI_PROVEN_PR_CHECK unset). Slice B owns proof-check.sh;
6612
+ # guarded on the file being sourced (declare -f) so this slice is
6613
+ # correct whether or not B is integrated. Best-effort, never fails PR.
6614
+ if [ "${LOKI_PROVEN_PR_CHECK:-0}" = "1" ] && declare -f post_verified_completion_check >/dev/null 2>&1; then
6615
+ post_verified_completion_check "${_auto_proof:-}" "$pr_url" || true
6616
+ fi
6413
6617
  else
6414
6618
  log_warn "Failed to create PR - branch pushed to: $branch_name"
6415
6619
  fi
@@ -10446,7 +10650,14 @@ start_dashboard() {
10446
10650
  # Start the FastAPI dashboard server
10447
10651
  # Dashboard module is at project root (parent of autonomy/)
10448
10652
  # LOKI_SKILL_DIR tells server.py where to find static files
10653
+ # LOKI_DASHBOARD_AUTOSTARTED=1 marks this as a run-launched (auto-started)
10654
+ # dashboard, distinct from an explicit `loki dashboard start`. The server
10655
+ # uses this so that, after the dashboard Stop button stops the last active
10656
+ # run, it may shut ITSELF down -- but ONLY when it was auto-started, never
10657
+ # when the user started it deliberately to keep monitoring. The explicit
10658
+ # cmd_dashboard_start path never sets this var (M4 fix).
10449
10659
  LOKI_TLS_CERT="${LOKI_TLS_CERT:-}" LOKI_TLS_KEY="${LOKI_TLS_KEY:-}" \
10660
+ LOKI_DASHBOARD_AUTOSTARTED=1 \
10450
10661
  LOKI_SKILL_DIR="${skill_dir}" PYTHONPATH="${skill_dir}" nohup "$python_cmd" -m dashboard.server > "$log_file" 2>&1 &
10451
10662
  DASHBOARD_PID=$!
10452
10663
  register_pid "$DASHBOARD_PID" "dashboard" "port=${DASHBOARD_PORT:-57374}"
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.89.1"
10
+ __version__ = "7.90.1"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -3325,12 +3325,14 @@ async def stop_running_project(request: Request, body: RunningProjectStopRequest
3325
3325
  except OSError:
3326
3326
  pass
3327
3327
  registry.mark_project_stopped(project_id)
3328
- return {
3328
+ _resp = {
3329
3329
  "success": True,
3330
3330
  "project_id": project_id,
3331
3331
  "stopped": stop_signaled,
3332
3332
  "already_stopped": not stop_signaled,
3333
3333
  }
3334
+ _resp.update(_dashboard_teardown_after_project_stop(path))
3335
+ return _resp
3334
3336
 
3335
3337
  # Write the STOP file so the runner's own cleanup STOP-branch fires for a
3336
3338
  # clean teardown. Only into the registry-resolved .loki dir.
@@ -3392,12 +3394,14 @@ async def stop_running_project(request: Request, body: RunningProjectStopRequest
3392
3394
 
3393
3395
  registry.mark_project_stopped(project_id)
3394
3396
 
3395
- return {
3397
+ _resp = {
3396
3398
  "success": True,
3397
3399
  "project_id": project_id,
3398
3400
  "stopped": stopped,
3399
3401
  "already_stopped": False,
3400
3402
  }
3403
+ _resp.update(_dashboard_teardown_after_project_stop(path))
3404
+ return _resp
3401
3405
 
3402
3406
 
3403
3407
  def _recover_spec_source(path: str) -> Optional[_Path]:
@@ -4057,6 +4061,17 @@ def get_compliance_status(request: Request, report_type: str = Query("soc2", ali
4057
4061
  # When set, _get_loki_dir() resolves .loki/ relative to this path instead of CWD.
4058
4062
  _active_project_dir: Optional[str] = None
4059
4063
 
4064
+ # M4 (v7.90.1): was this dashboard auto-started by a run (`loki start` ->
4065
+ # run.sh start_dashboard), or started deliberately by the user
4066
+ # (`loki dashboard start`)? Captured ONCE at import from the environment marker
4067
+ # that ONLY the auto-start path sets. Used to decide whether the dashboard may
4068
+ # shut ITSELF down after its Stop button stops the last active run: an
4069
+ # auto-started dashboard self-exits (it came up with the run, so it should go
4070
+ # down with the last run), while a user-started dashboard is left up and only
4071
+ # surfaces a non-destructive notice. Frozen at startup so a later child process
4072
+ # inheriting/clearing the var cannot flip the decision.
4073
+ _DASHBOARD_AUTOSTARTED: bool = os.environ.get("LOKI_DASHBOARD_AUTOSTARTED") == "1"
4074
+
4060
4075
 
4061
4076
  def _get_loki_dir() -> _Path:
4062
4077
  """Get LOKI_DIR, refreshing from env on each call for consistency.
@@ -4364,6 +4379,126 @@ def _reap_orchestrators_until_clear(project_dir: _Path, expected_cwd: str,
4364
4379
  return (found_any, not _find_orchestrator_pids_for_dir(project_dir))
4365
4380
 
4366
4381
 
4382
+ def _other_runs_alive(exclude_path: Optional[str] = None) -> bool:
4383
+ """True if ANY registered project OTHER than exclude_path still has a live
4384
+ orchestrator pid. Mirrors run.sh's CLEAR/KEEP check (and cmd_stop's): a
4385
+ project counts as alive only when its recorded pid is still running. The
4386
+ just-stopped project must already be marked stopped in the registry (its pid
4387
+ set to None by mark_project_stopped) before this is called, so it is not
4388
+ self-counted; exclude_path is an extra belt-and-suspenders guard by realpath.
4389
+ Best-effort: any registry/probe error degrades to True (KEEP) so we NEVER
4390
+ tear the dashboard down on uncertain information.
4391
+ """
4392
+ try:
4393
+ exclude_real = None
4394
+ if exclude_path:
4395
+ try:
4396
+ exclude_real = os.path.realpath(exclude_path)
4397
+ except OSError:
4398
+ exclude_real = os.path.abspath(exclude_path)
4399
+ for p in registry.list_projects(include_inactive=True):
4400
+ path = p.get("path", "")
4401
+ if exclude_real and path:
4402
+ try:
4403
+ if os.path.realpath(path) == exclude_real:
4404
+ continue
4405
+ except OSError:
4406
+ if os.path.abspath(path) == exclude_real:
4407
+ continue
4408
+ pid = p.get("pid")
4409
+ if isinstance(pid, int) and pid > 0 and not _pid_is_gone(pid):
4410
+ return True
4411
+ return False
4412
+ except Exception:
4413
+ # Unknown -> assume something is still running so we never wrongly kill
4414
+ # the dashboard.
4415
+ return True
4416
+
4417
+
4418
+ def _self_shutdown_after_response(delay_s: float = 1.0) -> None:
4419
+ """Gracefully shut THIS dashboard server down shortly after the current HTTP
4420
+ response has been sent. Used only after the dashboard's own Stop button stops
4421
+ the LAST active run and only when this dashboard was auto-started (M4).
4422
+
4423
+ Why a delayed background thread (not an inline exit): the Stop request is
4424
+ still in flight when this is decided. We must let FastAPI/uvicorn finish
4425
+ writing the response (so the UI gets its JSON) before the process goes away.
4426
+ A short-delay daemon thread sends SIGTERM to our OWN pid, which uvicorn
4427
+ handles as a graceful shutdown (runs the lifespan teardown). This sidesteps
4428
+ the fragile path where the dashboard relied on the orchestrator's cleanup
4429
+ trap to kill it -- a path that a SIGKILL of the orchestrator (after the 5s
4430
+ window) or a uvicorn graceful-shutdown deadlock could bypass, leaving the
4431
+ dashboard lingering (the reported M4 bug). Best-effort; never raises into
4432
+ the request.
4433
+ """
4434
+ import signal as _signal
4435
+
4436
+ def _kill_self() -> None:
4437
+ try:
4438
+ time.sleep(max(0.0, delay_s))
4439
+ except Exception:
4440
+ pass
4441
+ try:
4442
+ logger.info(
4443
+ "Auto-started dashboard: last active run stopped and no other "
4444
+ "run is alive; shutting self down (pid=%s).", os.getpid())
4445
+ except Exception:
4446
+ pass
4447
+ try:
4448
+ os.kill(os.getpid(), _signal.SIGTERM)
4449
+ except OSError:
4450
+ # Last resort: hard-exit this process only (never touches others).
4451
+ os._exit(0)
4452
+
4453
+ try:
4454
+ threading.Thread(target=_kill_self, name="loki-dash-selfstop",
4455
+ daemon=True).start()
4456
+ except Exception:
4457
+ # If we cannot even spawn the thread, do nothing: a lingering dashboard
4458
+ # is strictly safer than any broader action.
4459
+ pass
4460
+
4461
+
4462
+ def _dashboard_teardown_after_project_stop(stopped_path: Optional[str]) -> dict:
4463
+ """Decide what happens to THIS dashboard after a per-project Stop, and return
4464
+ fields to merge into the Stop response (M4).
4465
+
4466
+ Rules (conservative; never touches any process but this server's own):
4467
+ - If another registered run is still alive -> KEEP: do nothing, the
4468
+ dashboard stays up because other projects still need it.
4469
+ - If no other run is alive (CLEAR) and this dashboard was AUTO-STARTED by a
4470
+ run -> schedule a graceful self-shutdown after the response is sent. It
4471
+ came up with a run, so it goes down with the last run.
4472
+ - If CLEAR but the dashboard was started DELIBERATELY by the user
4473
+ (`loki dashboard start`) -> do NOT self-kill; return a non-destructive
4474
+ notice so the UI can tell the user how to stop it (loki dashboard stop).
4475
+
4476
+ Returns one of:
4477
+ {"dashboard": "kept"} # other runs alive
4478
+ {"dashboard": "stopping", ...} # auto-started + CLEAR
4479
+ {"dashboard": "idle", "notice": "...", ...} # user-started + CLEAR
4480
+ """
4481
+ try:
4482
+ if _other_runs_alive(exclude_path=stopped_path):
4483
+ return {"dashboard": "kept"}
4484
+ if _DASHBOARD_AUTOSTARTED:
4485
+ _self_shutdown_after_response()
4486
+ return {
4487
+ "dashboard": "stopping",
4488
+ "dashboard_autostarted": True,
4489
+ }
4490
+ return {
4491
+ "dashboard": "idle",
4492
+ "dashboard_autostarted": False,
4493
+ "notice": ("No active runs. This dashboard was started manually; "
4494
+ "stop it with: loki dashboard stop"),
4495
+ }
4496
+ except Exception:
4497
+ # Any failure in the decision must never break the Stop response, and
4498
+ # must never kill the dashboard on uncertain info.
4499
+ return {"dashboard": "kept"}
4500
+
4501
+
4367
4502
  def _pid_is_gone(pid: int) -> bool:
4368
4503
  """True if pid no longer exists OR is a zombie/defunct (effectively dead,
4369
4504
  just not yet reaped by its parent). os.kill(pid,0) succeeds on a zombie, so
@@ -10078,6 +10213,25 @@ def _safe_proof_run_dir(run_id: str) -> _Path:
10078
10213
  return _Path(target)
10079
10214
 
10080
10215
 
10216
+ def _proof_pr_url(run_dir: _Path) -> Optional[str]:
10217
+ """Return the PR URL linked to this run, or None.
10218
+
10219
+ Reads the optional <run_dir>/pr.json that the run.sh auto-PR path writes at
10220
+ PR-creation time ({"run_id": "...", "pr_url": "..."}). The file is read only
10221
+ inside the already-validated run dir (the caller passes either an iterdir()
10222
+ entry or a _safe_proof_run_dir result -- never a raw run_id), so there is no
10223
+ additional traversal surface. Missing/unreadable/non-dict pr.json or an
10224
+ empty/non-string pr_url -> None, never an error. Most proofs have no PR.
10225
+ """
10226
+ pr_data = _safe_json_read(run_dir / "pr.json", default=None)
10227
+ if not isinstance(pr_data, dict):
10228
+ return None
10229
+ url = pr_data.get("pr_url")
10230
+ if isinstance(url, str) and url:
10231
+ return url
10232
+ return None
10233
+
10234
+
10081
10235
  @app.get("/api/proofs", dependencies=[Depends(auth.require_scope("read"))])
10082
10236
  async def list_proofs():
10083
10237
  """List proof-of-run artifacts for the active project's .loki/proofs/."""
@@ -10096,6 +10250,11 @@ async def list_proofs():
10096
10250
  data = _safe_json_read(proof_json, default=None)
10097
10251
  if not isinstance(data, dict):
10098
10252
  continue
10253
+ # Deterministic honesty headline (single source of truth, same access as
10254
+ # proofs_summary's bucketing). Read, never recomputed. The list endpoint
10255
+ # surfaces it so the panel can show the verdict without a second fetch.
10256
+ honesty = data.get("honesty")
10257
+ headline = honesty.get("headline") if isinstance(honesty, dict) else None
10099
10258
  items.append({
10100
10259
  "run_id": data.get("run_id", entry.name),
10101
10260
  "generated_at": data.get("generated_at"),
@@ -10103,6 +10262,8 @@ async def list_proofs():
10103
10262
  "cost_usd": (data.get("cost") or {}).get("usd"),
10104
10263
  "files_changed": (data.get("files_changed") or {}).get("count"),
10105
10264
  "final_verdict": (data.get("council") or {}).get("final_verdict"),
10265
+ "headline": headline,
10266
+ "pr_url": _proof_pr_url(entry),
10106
10267
  "has_html": (entry / "index.html").is_file(),
10107
10268
  })
10108
10269
  # Newest first when generated_at is present.
@@ -10176,6 +10337,10 @@ async def get_proof(run_id: str):
10176
10337
  data = _safe_json_read(proof_json, default=None)
10177
10338
  if not isinstance(data, dict):
10178
10339
  raise HTTPException(status_code=500, detail="proof.json unreadable")
10340
+ # Surface the optional PR linkage alongside the proof. The proof.json itself
10341
+ # already carries honesty.headline; we only add pr_url so the panel can show
10342
+ # "PR #N -> <headline>". Absent pr.json -> pr_url null, never an error.
10343
+ data["pr_url"] = _proof_pr_url(run_dir)
10179
10344
  return JSONResponse(content=data)
10180
10345
 
10181
10346