Show live experiment progress in app

This commit is contained in:
saymrwulf 2026-04-29 11:55:19 +02:00
parent 7dea61f9ce
commit 69bd3ea420
8 changed files with 306 additions and 8 deletions

View file

@ -59,6 +59,8 @@ Print the latest mirror snapshot:
./scripts/ratchet mirror
```
When an experiment watch is running, Flight Deck now shows live progress: run id, countdown, approximate cycle count, progress bar, and local finish time. The countdown updates every second; backend state refreshes roughly every 30 seconds.
Advanced fallback for a 6-hour CLI monitoring session:
```bash

View file

@ -91,6 +91,8 @@ If you ask for help, this file is the fastest way to show the exact app state in
./scripts/ratchet mirror
```
When a watch is running, `Flight Deck` shows a running-experiment progress panel with the run id, countdown, approximate cycle count, progress bar, and local finish time. The countdown updates every second; backend state refreshes roughly every 30 seconds.
## Research Pathway
The app has three time horizons:

View file

@ -76,6 +76,8 @@ Layer 7 is the visual self-reflection layer.
The SwiftUI app renders a `Reality Mirror` HUD and tab. It writes the semantic state it believes it is showing to `data/app_visual_state.md` and `data/app_visual_state.json`. This is not screenshot OCR; it is the app's own rendered-state ledger.
During active watches, the backend exposes `active_watch_details` through `app-state`: run id, PID, start time, planned cycles, interval, elapsed seconds, remaining seconds, progress percent, next-cycle ETA, and estimated finish time. The SwiftUI Flight Deck renders this as a live progress panel and refreshes backend state roughly every 30 seconds.
### Data Flow
Normal data flow:

View file

@ -143,7 +143,7 @@ What you do: do nothing. Leave the app open or closed. The background engine kee
`WAIT` means a watch is already running.
What you do: do not start anything else.
What you do: do not start anything else. The Flight Deck should show a running-experiment progress panel with progress percent, approximate cycle count, and local finish time.
`COOLDOWN` means the last watch already produced evidence and the system is intentionally waiting.
@ -189,6 +189,8 @@ It collects repeated samples:
The default watch stage used by the forever engine is 2 hours. It samples every 5 minutes, so a full stage normally has 24 cycles.
During a watch, the app shows the active run id, countdown, progress bar, approximate cycle count, and local finish time. The visual countdown updates every second. Backend state refreshes about every 30 seconds.
## What The Forever Engine Does
`Start Forever Engine` starts a repo-local background monitor loop.

View file

@ -147,6 +147,11 @@ struct FlightDeckApp: View {
.task {
await store.refresh()
store.writeRealitySnapshot(section: selection)
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 30_000_000_000)
await store.refresh()
store.writeRealitySnapshot(section: selection)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 4.8).repeatForever(autoreverses: true)) {
@ -395,6 +400,10 @@ struct FlightDeckView: View {
InstrumentRibbon(appState: store.appState)
if let activeWatch = store.appState?.operatorState.activeWatchDetails {
ActiveWatchProgressPanel(activeWatch: activeWatch)
}
EngineConsole(store: store)
}
.frame(width: max(430, proxy.size.width * 0.38))
@ -737,6 +746,86 @@ struct InstrumentChip: View {
}
}
struct ActiveWatchProgressPanel: View {
let activeWatch: ActiveWatchPayload
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in
let live = liveMetrics(now: context.date)
LiquidGlassSurface(tint: .orange.opacity(0.20), cornerRadius: 32) {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Label("Running experiment", systemImage: "timer")
.font(.title3.weight(.black))
Text(activeWatch.runId)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
Spacer()
Text(live.remainingText)
.font(.title3.monospacedDigit().weight(.black))
.foregroundStyle(.orange)
}
ProgressView(value: live.progress)
.tint(.orange)
HStack(spacing: 12) {
WatchMetric("progress", "\(Int((live.progress * 100).rounded()))%", "done")
WatchMetric("cycles", "\(live.cycleEstimate)/\(activeWatch.plannedCycles)", "about")
WatchMetric("finish", activeWatch.estimatedFinishLocal.shortClockText, "local")
}
Text("The app refreshes backend state every 30 seconds; this bar counts down locally every second.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(18)
}
}
}
private func liveMetrics(now: Date) -> (progress: Double, cycleEstimate: Int, remainingText: String) {
guard let started = activeWatch.startedDate, let finish = activeWatch.finishDate else {
return (Double(activeWatch.progressPercent) / 100.0, activeWatch.completedCyclesEstimate, "\(activeWatch.remainingSeconds / 60)m left")
}
let total = max(1, finish.timeIntervalSince(started))
let elapsed = min(total, max(0, now.timeIntervalSince(started)))
let remaining = max(0, Int(finish.timeIntervalSince(now)))
let cycle = min(activeWatch.plannedCycles, max(1, Int(elapsed / Double(activeWatch.intervalSeconds)) + 1))
return (elapsed / total, cycle, formatDuration(remaining))
}
}
struct WatchMetric: View {
let title: String
let value: String
let unit: String
init(_ title: String, _ value: String, _ unit: String) {
self.title = title
self.value = value
self.unit = unit
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption.weight(.heavy))
.textCase(.uppercase)
.foregroundStyle(.secondary)
Text(value)
.font(.headline.monospacedDigit().weight(.black))
Text(unit)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct HashflowView: View {
@ObservedObject var store: RatchetStore
@ -1536,6 +1625,9 @@ struct RenderedReality: Codable {
let latestStrategyAction: String
let latestReport: String
let activeWatch: String
let activeWatchProgress: String
let activeWatchEta: String
let activeWatchRemaining: String
let completedWatch: String
let activeManualExposure: String
let braiinsFreshness: String
@ -1554,6 +1646,8 @@ struct RenderedReality: Codable {
RealityRow(label: "engine", value: engineRunning ? "running" : "stopped", unit: "state"),
RealityRow(label: "strategy", value: latestStrategyAction, unit: "proposal"),
RealityRow(label: "braiins", value: braiinsFreshness, unit: "freshness"),
RealityRow(label: "watch progress", value: activeWatchProgress, unit: "live"),
RealityRow(label: "watch ETA", value: activeWatchEta, unit: "local"),
RealityRow(label: "manual exposure", value: activeManualExposure, unit: "blocker"),
] + instrumentRows
}
@ -1587,6 +1681,7 @@ struct RenderedReality: Codable {
let operatorState = appState?.operatorState
let engineStatus = appState?.engineStatus
let activePositions = operatorState?.activeManualPositions ?? []
let activeWatchDetails = operatorState?.activeWatchDetails
let completedWatch = operatorState?.completedWatch
let buttons = visibleButtons(section: section, appState: appState, isWorking: isWorking)
let truths = operatorTruths(
@ -1615,6 +1710,9 @@ struct RenderedReality: Codable {
latestStrategyAction: operatorState?.action ?? "none",
latestReport: operatorState?.latestReport ?? "none",
activeWatch: operatorState?.activeWatch ?? "none",
activeWatchProgress: activeWatchDetails.map { "\($0.progressPercent)% (\($0.completedCyclesEstimate)/\($0.plannedCycles) cycles)" } ?? "none",
activeWatchEta: activeWatchDetails?.estimatedFinishLocal ?? "none",
activeWatchRemaining: activeWatchDetails.map { formatDuration($0.remainingSeconds) } ?? "none",
completedWatch: completedWatch.map { "\($0.reportPath), remaining \($0.remainingMinutes)m, earliest \($0.earliestActionLocal)" } ?? "none",
activeManualExposure: activePositions.isEmpty ? "none" : activePositions.joined(separator: "; "),
braiinsFreshness: freshnessText(operatorState),
@ -1794,6 +1892,9 @@ enum RealitySnapshotWriter {
"- latest_braiins_sample: \(reality.latestBraiinsSample)",
"- latest_report: \(reality.latestReport)",
"- active_watch: \(reality.activeWatch)",
"- active_watch_progress: \(reality.activeWatchProgress)",
"- active_watch_eta: \(reality.activeWatchEta)",
"- active_watch_remaining: \(reality.activeWatchRemaining)",
"- completed_watch: \(reality.completedWatch)",
"- active_manual_exposure: \(reality.activeManualExposure)",
"",
@ -1871,6 +1972,7 @@ struct OperatorStatePayload: Codable {
let latestOceanTimestamp: String?
let latestMarketTimestamp: String?
let activeManualPositions: [String]
let activeWatchDetails: ActiveWatchPayload?
enum CodingKeys: String, CodingKey {
case hasOcean = "has_ocean"
@ -1885,9 +1987,49 @@ struct OperatorStatePayload: Codable {
case latestOceanTimestamp = "latest_ocean_timestamp"
case latestMarketTimestamp = "latest_market_timestamp"
case activeManualPositions = "active_manual_positions"
case activeWatchDetails = "active_watch_details"
}
}
struct ActiveWatchPayload: Codable {
let label: String
let runId: String
let pid: Int?
let startedUtc: String
let plannedCycles: Int
let intervalSeconds: Int
let totalSeconds: Int
let elapsedSeconds: Int
let remainingSeconds: Int
let progressPercent: Int
let completedCyclesEstimate: Int
let nextCycleEtaUtc: String?
let nextCycleEtaLocal: String?
let estimatedFinishUtc: String
let estimatedFinishLocal: String
enum CodingKeys: String, CodingKey {
case label
case runId = "run_id"
case pid
case startedUtc = "started_utc"
case plannedCycles = "planned_cycles"
case intervalSeconds = "interval_seconds"
case totalSeconds = "total_seconds"
case elapsedSeconds = "elapsed_seconds"
case remainingSeconds = "remaining_seconds"
case progressPercent = "progress_percent"
case completedCyclesEstimate = "completed_cycles_estimate"
case nextCycleEtaUtc = "next_cycle_eta_utc"
case nextCycleEtaLocal = "next_cycle_eta_local"
case estimatedFinishUtc = "estimated_finish_utc"
case estimatedFinishLocal = "estimated_finish_local"
}
var startedDate: Date? { parseISODate(startedUtc) }
var finishDate: Date? { parseISODate(estimatedFinishUtc) }
}
struct CompletedWatchPayload: Codable {
let reportPath: String
let ageMinutes: Int
@ -1992,6 +2134,13 @@ extension String {
var lastPathComponent: String {
URL(fileURLWithPath: self).lastPathComponent
}
var shortClockText: String {
guard let date = parseISODate(self) else { return self }
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
return formatter.string(from: date)
}
}
func sats(_ btcText: String) -> String {
@ -2000,6 +2149,30 @@ func sats(_ btcText: String) -> String {
return "\(sats) sats"
}
func parseISODate(_ text: String) -> Date? {
if let date = ISO8601DateFormatter().date(from: text) {
return date
}
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX"
return formatter.date(from: text)
}
func formatDuration(_ seconds: Int) -> String {
let safe = max(0, seconds)
let hours = safe / 3600
let minutes = (safe % 3600) / 60
let secs = safe % 60
if hours > 0 {
return "\(hours)h \(minutes)m left"
}
if minutes > 0 {
return "\(minutes)m \(secs)s left"
}
return "\(secs)s left"
}
func phText(_ ehText: String) -> String {
guard let eh = Double(ehText) else { return "n/a" }
return String(format: "%.3f", eh * 1000)

View file

@ -23,6 +23,25 @@ class CompletedWatch:
earliest_action_local: str
@dataclass(frozen=True)
class ActiveWatchDetails:
label: str
run_id: str
pid: int | None
started_utc: str
planned_cycles: int
interval_seconds: int
total_seconds: int
elapsed_seconds: int
remaining_seconds: int
progress_percent: int
completed_cycles_estimate: int
next_cycle_eta_utc: str | None
next_cycle_eta_local: str | None
estimated_finish_utc: str
estimated_finish_local: str
@dataclass(frozen=True)
class OperatorState:
has_ocean: bool
@ -37,6 +56,7 @@ class OperatorState:
latest_ocean_timestamp: str | None
latest_market_timestamp: str | None
active_manual_positions: list[str]
active_watch_details: ActiveWatchDetails | None = None
def get_operator_state(conn) -> OperatorState:
@ -45,11 +65,13 @@ def get_operator_state(conn) -> OperatorState:
proposal = latest_proposal(conn)
latest_report = _latest_report()
freshness = _freshness_minutes(market.timestamp_utc if market else None)
active_watch_details = _active_watch_details()
active_watch = active_watch_details.label if active_watch_details else _active_watch_from_process_table()
return OperatorState(
has_ocean=ocean is not None,
has_market=market is not None,
action=proposal.action if proposal else None,
active_watch=_active_watch(),
active_watch=active_watch,
completed_watch=_recent_completed_watch(latest_report, market.timestamp_utc if market else None),
is_fresh=freshness is not None and freshness <= 30,
freshness_minutes=freshness,
@ -58,6 +80,7 @@ def get_operator_state(conn) -> OperatorState:
latest_ocean_timestamp=ocean.timestamp_utc if ocean else None,
latest_market_timestamp=market.timestamp_utc if market else None,
active_manual_positions=_active_manual_positions(conn),
active_watch_details=active_watch_details,
)
@ -80,6 +103,9 @@ def build_operator_cockpit(conn) -> str:
f" Research stage: {_research_stage(state.active_watch, state.completed_watch)}",
]
if state.active_watch_details:
lines.extend(_active_watch_status_lines(state.active_watch_details))
if state.completed_watch:
lines.extend(_cooldown_status_lines(state.completed_watch))
@ -406,6 +432,17 @@ def _progress_bar(elapsed: int, total: int, width: int = 20) -> str:
return "[" + ("#" * filled) + ("-" * (width - filled)) + "]"
def _active_watch_status_lines(active_watch: ActiveWatchDetails) -> list[str]:
remaining_minutes = max(0, active_watch.remaining_seconds // 60)
return [
f" Active watch progress: {_progress_bar(active_watch.elapsed_seconds, active_watch.total_seconds)} {active_watch.progress_percent}%",
f" Active watch cycles: about {active_watch.completed_cycles_estimate}/{active_watch.planned_cycles}",
f" Active watch started: {active_watch.started_utc}",
f" Active watch ETA: {active_watch.estimated_finish_local}",
f" Active watch remaining: about {remaining_minutes} minutes",
]
def _running_runs() -> list[str]:
if not EXPERIMENT_LOG.exists():
return []
@ -423,26 +460,73 @@ def _running_runs() -> list[str]:
def _active_watch() -> str | None:
from_state_file = _active_watch_from_state_file()
if from_state_file:
return from_state_file
details = _active_watch_details()
if details:
return details.label
return _active_watch_from_process_table()
def _active_watch_from_state_file() -> str | None:
def _active_watch_details() -> ActiveWatchDetails | None:
if not ACTIVE_WATCH.exists():
return None
try:
payload = json.loads(ACTIVE_WATCH.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return "state file exists but is unreadable"
return None
pid = payload.get("pid")
run_id = payload.get("run_id", "unknown-run")
if isinstance(pid, int) and _pid_exists(pid):
return f"{run_id} pid={pid}"
return _active_watch_details_from_payload(payload, run_id, pid)
if not isinstance(pid, int):
return _active_watch_details_from_payload(payload, run_id, None)
return None
def _active_watch_details_from_payload(
payload: dict[str, object],
run_id: str,
pid: int | None,
) -> ActiveWatchDetails | None:
started = _parse_utc(str(payload.get("started_utc", "")))
if started is None:
return None
try:
planned_cycles = int(payload.get("planned_cycles", 0))
interval_seconds = int(payload.get("interval_seconds", 0))
except (TypeError, ValueError):
return None
if planned_cycles <= 0 or interval_seconds <= 0:
return None
total_seconds = planned_cycles * interval_seconds
now = datetime.now(UTC)
elapsed_seconds = max(0, int((now - started).total_seconds()))
remaining_seconds = max(0, total_seconds - elapsed_seconds)
progress_percent = min(100, max(0, int((elapsed_seconds / total_seconds) * 100)))
completed_cycles_estimate = min(planned_cycles, max(1, (elapsed_seconds // interval_seconds) + 1))
next_cycle_index = completed_cycles_estimate if completed_cycles_estimate < planned_cycles else None
next_cycle_eta = None if next_cycle_index is None else started.timestamp() + (next_cycle_index * interval_seconds)
finish = datetime.fromtimestamp(started.timestamp() + total_seconds, UTC)
next_cycle_utc = datetime.fromtimestamp(next_cycle_eta, UTC) if next_cycle_eta else None
return ActiveWatchDetails(
label=f"{run_id} pid={pid}" if pid is not None else run_id,
run_id=run_id,
pid=pid,
started_utc=started.isoformat(timespec="seconds"),
planned_cycles=planned_cycles,
interval_seconds=interval_seconds,
total_seconds=total_seconds,
elapsed_seconds=min(elapsed_seconds, total_seconds),
remaining_seconds=remaining_seconds,
progress_percent=progress_percent,
completed_cycles_estimate=completed_cycles_estimate,
next_cycle_eta_utc=next_cycle_utc.isoformat(timespec="seconds") if next_cycle_utc else None,
next_cycle_eta_local=next_cycle_utc.astimezone().isoformat(timespec="seconds") if next_cycle_utc else None,
estimated_finish_utc=finish.isoformat(timespec="seconds"),
estimated_finish_local=finish.astimezone().isoformat(timespec="seconds"),
)
def _active_watch_from_process_table() -> str | None:
if os.environ.get("BRAIINS_RATCHET_IGNORE_PROCESS_WATCH") == "1":
return None

View file

@ -5,8 +5,10 @@ import unittest
from unittest.mock import patch
from braiins_ratchet.guidance import (
ActiveWatchDetails,
CompletedWatch,
_cooldown_status_lines,
_active_watch_status_lines,
_do_this_now,
_pathway_forecast,
build_operator_cockpit,
@ -149,6 +151,32 @@ class GuidanceTests(unittest.TestCase):
self.assertIn("Earliest next action: 2026-04-28T00:00:00+02:00", text)
self.assertIn("Cooldown remaining: 270 minutes", text)
def test_active_watch_status_includes_progress_and_eta(self) -> None:
active_watch = ActiveWatchDetails(
label="run-example pid=123",
run_id="run-example",
pid=123,
started_utc="2026-04-29T08:48:06+00:00",
planned_cycles=24,
interval_seconds=300,
total_seconds=7200,
elapsed_seconds=1800,
remaining_seconds=5400,
progress_percent=25,
completed_cycles_estimate=7,
next_cycle_eta_utc="2026-04-29T09:18:06+00:00",
next_cycle_eta_local="2026-04-29T11:18:06+02:00",
estimated_finish_utc="2026-04-29T10:48:06+00:00",
estimated_finish_local="2026-04-29T12:48:06+02:00",
)
text = "\n".join(_active_watch_status_lines(active_watch))
self.assertIn("Active watch progress: [#####---------------] 25%", text)
self.assertIn("Active watch cycles: about 7/24", text)
self.assertIn("Active watch ETA: 2026-04-29T12:48:06+02:00", text)
self.assertIn("Active watch remaining: about 90 minutes", text)
def _completed_watch(age_minutes: int) -> CompletedWatch:
return CompletedWatch(
@ -165,6 +193,7 @@ def _isolated_operator_files():
return patch.multiple(
"braiins_ratchet.guidance",
_active_watch=lambda: None,
_active_watch_details=lambda: None,
_latest_report=lambda: None,
_running_runs=lambda: [],
)

View file

@ -93,6 +93,10 @@ class MacAppPackagingTest(unittest.TestCase):
self.assertIn("RealityHUD", text)
self.assertIn("RenderedReality", text)
self.assertIn("RealitySnapshotWriter", text)
self.assertIn("ActiveWatchProgressPanel", text)
self.assertIn("activeWatchDetails", text)
self.assertIn("TimelineView(.periodic", text)
self.assertIn("The app refreshes backend state every 30 seconds", text)
self.assertIn("AppStatePayload", text)
self.assertIn("EngineStatusPayload", text)
self.assertIn("loadAppState", text)