Add durable lifecycle supervisor and mac app

This commit is contained in:
saymrwulf 2026-04-27 18:38:57 +02:00
parent 27f27992c3
commit d8e8113c59
13 changed files with 653 additions and 0 deletions

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ data/*.sqlite-shm
data/*.sqlite-wal
data/raw/
*.log
macos/BraiinsRatchet/.build/

View file

@ -29,6 +29,21 @@ If the cockpit is in cooldown and you want the app to wait until the earliest ne
It prints the exact monitor-only plan and asks `yes/no` before doing anything.
For the durable forever lifecycle supervisor:
```bash
./scripts/ratchet supervise
```
It persists lifecycle state in `data/ratchet.sqlite`. If the process crashes or the Mac reboots, start the same command again and it resumes from SQLite.
For the native macOS SwiftUI shell:
```bash
cd macos/BraiinsRatchet
swift run BraiinsRatchetMac
```
For a 6-hour monitoring session:
```bash

View file

@ -73,6 +73,48 @@ It only runs after you type `yes`.
It is still monitor-only. It never places, changes, or cancels Braiins orders.
## Forever Supervisor
For the full autoresearch lifecycle, run:
```bash
./scripts/ratchet supervise
```
The supervisor is the long-running engine. It:
1. Loads persisted state from `data/ratchet.sqlite`.
2. Waits through cooldown if cooldown is active.
3. Runs the next passive watch when due.
4. Writes reports and lifecycle events.
5. Re-enters cooldown.
6. Repeats until you stop it.
If it crashes or the Mac reboots, start the same command again. It resumes from SQLite.
Use this to inspect persisted state without starting the loop:
```bash
./scripts/ratchet supervise --status
```
## Native Mac App
The native SwiftUI shell is in:
```text
macos/BraiinsRatchet
```
Run it from source:
```bash
cd macos/BraiinsRatchet
swift run BraiinsRatchetMac
```
The app is a native cockpit over the same durable Python lifecycle engine.
## Research Pathway
The cockpit has two different time horizons:

View file

@ -46,6 +46,22 @@ If approved, it waits until the earliest next action time, runs one fresh monito
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli pipeline
```
## `supervise`
Runs the durable forever lifecycle supervisor.
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli supervise
```
Status only:
```bash
PYTHONPATH=src ./.venv/bin/python -m braiins_ratchet.cli supervise --status
```
Crash/reboot contract: start `./scripts/ratchet supervise` again and it resumes from persisted SQLite lifecycle state.
## `init-db`
Creates `data/ratchet.sqlite` if it does not exist.

View file

@ -58,6 +58,24 @@ During cooldown, the cockpit prints a progress bar, remaining minutes, and the e
The pipeline prints its plan and asks for `yes` or `no` before doing anything. It never places Braiins orders.
## Forever Lifecycle
For unattended monitor-only autoresearch:
```bash
./scripts/ratchet supervise
```
The supervisor is stateful. It writes lifecycle state and events into `data/ratchet.sqlite`, so a restart can continue from the current phase instead of starting over.
Check the persisted lifecycle state:
```bash
./scripts/ratchet supervise --status
```
This is still monitor-only. Manual Braiins bids remain outside the app unless separately recorded.
If a run already happened before automatic bookkeeping was available, reconstruct it from the stored SQLite snapshots:
```bash

View file

@ -0,0 +1,18 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "BraiinsRatchetMac",
platforms: [
.macOS(.v15)
],
products: [
.executable(name: "BraiinsRatchetMac", targets: ["BraiinsRatchetMac"])
],
targets: [
.executableTarget(
name: "BraiinsRatchetMac"
)
]
)

View file

@ -0,0 +1,23 @@
# Braiins Ratchet Mac
Native SwiftUI shell for the durable Braiins Ratchet lifecycle engine.
The Python supervisor remains the source of truth. This app reads the same repository-local SQLite state through `./scripts/ratchet`.
## Run From Source
```bash
cd macos/BraiinsRatchet
swift run BraiinsRatchetMac
```
## Current Scope
- Native macOS SwiftUI cockpit.
- Liquid-glass-inspired material panels.
- Buttons for cockpit, lifecycle status, automation proposal, and full report.
- Monitor-only. It never places Braiins orders.
## Product Direction
The next production step is packaging this SwiftUI shell as a signed `.app` and wiring LaunchAgent controls for the durable supervisor.

View file

@ -0,0 +1,160 @@
import SwiftUI
@main
struct BraiinsRatchetApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.frame(minWidth: 980, minHeight: 680)
}
.windowStyle(.hiddenTitleBar)
}
}
struct ContentView: View {
@State private var output = "Press Refresh Cockpit."
@State private var isRunning = false
var body: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.05, green: 0.07, blue: 0.08),
Color(red: 0.08, green: 0.14, blue: 0.15),
Color(red: 0.17, green: 0.20, blue: 0.17)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
VStack(alignment: .leading, spacing: 22) {
header
controls
outputPanel
}
.padding(30)
}
.task {
await runRatchet(["next"])
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Braiins Ratchet")
.font(.system(size: 44, weight: .black, design: .rounded))
.foregroundStyle(.white)
Text("Persistent monitor-only autoresearch cockpit")
.font(.system(size: 18, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(0.72))
}
}
private var controls: some View {
HStack(spacing: 12) {
glassButton("Refresh Cockpit") {
Task { await runRatchet(["next"]) }
}
glassButton("Lifecycle Status") {
Task { await runRatchet(["supervise", "--status"]) }
}
glassButton("Automation Plan") {
Task { await runRatchet(["pipeline"], input: "no\n") }
}
glassButton("Full Report") {
Task { await runRatchet(["report"]) }
}
if isRunning {
ProgressView()
.controlSize(.small)
.padding(.leading, 8)
}
}
}
private var outputPanel: some View {
ScrollView {
Text(output)
.font(.system(.body, design: .monospaced))
.foregroundStyle(.white.opacity(0.92))
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
.padding(22)
}
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 28, style: .continuous)
.stroke(.white.opacity(0.16), lineWidth: 1)
)
.shadow(color: .black.opacity(0.35), radius: 28, x: 0, y: 18)
}
private func glassButton(_ title: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(title)
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
.buttonStyle(.plain)
.background(.thinMaterial, in: Capsule())
.overlay(Capsule().stroke(.white.opacity(0.18), lineWidth: 1))
.disabled(isRunning)
}
@MainActor
private func runRatchet(_ arguments: [String], input: String? = nil) async {
isRunning = true
output = "Running ./scripts/ratchet \(arguments.joined(separator: " ")) ..."
let result = await RatchetProcess.run(arguments: arguments, input: input)
output = result
isRunning = false
}
}
enum RatchetProcess {
static func run(arguments: [String], input: String? = nil) async -> String {
await Task.detached {
let packageRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let repoRoot = packageRoot
.deletingLastPathComponent()
.deletingLastPathComponent()
let script = repoRoot.appendingPathComponent("scripts/ratchet").path
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = ["-lc", ([script] + arguments).map(shellQuote).joined(separator: " ")]
process.currentDirectoryURL = repoRoot
let outputPipe = Pipe()
let inputPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = outputPipe
process.standardInput = inputPipe
do {
try process.run()
if let input {
inputPipe.fileHandleForWriting.write(Data(input.utf8))
}
inputPipe.fileHandleForWriting.closeFile()
process.waitUntilExit()
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
let text = String(data: data, encoding: .utf8) ?? ""
return text.isEmpty ? "Command finished with no output." : text
} catch {
return "Failed to run ratchet command: \(error.localizedDescription)"
}
}.value
}
private static func shellQuote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
}

View file

@ -14,6 +14,7 @@ Commands:
once Fetch one fresh sample, then print the cockpit.
watch [hours] Run repeated monitor cycles for N hours. Default: 6.
pipeline Propose timed automation, then ask yes/no.
supervise Run the durable forever lifecycle supervisor.
report Print the latest stored report without fetching new data.
experiments Print the Karpathy-style experiment ledger.
retro SINCE [UNTIL] Write a retroactive report from stored snapshots.
@ -28,6 +29,7 @@ Examples:
./scripts/ratchet once
./scripts/ratchet watch 6
./scripts/ratchet pipeline
./scripts/ratchet supervise
./scripts/ratchet report
./scripts/ratchet experiments
./scripts/ratchet retro 2026-04-25T19:08:00+00:00 2026-04-25T21:05:00+00:00
@ -111,6 +113,10 @@ cmd_pipeline() {
run_python -m braiins_ratchet.cli pipeline "$@"
}
cmd_supervise() {
run_python -m braiins_ratchet.cli supervise "$@"
}
cmd_experiments() {
run_python -m braiins_ratchet.cli experiments
}
@ -152,6 +158,7 @@ main() {
once) cmd_once "$@" ;;
watch) cmd_watch "$@" ;;
pipeline|auto) cmd_pipeline "$@" ;;
supervise|daemon) cmd_supervise "$@" ;;
report) cmd_report "$@" ;;
experiments) cmd_experiments "$@" ;;
retro) cmd_retro "$@" ;;

View file

@ -17,6 +17,7 @@ from .experiments import (
write_retro_report,
)
from .guidance import build_operator_cockpit
from .lifecycle import render_lifecycle_status, render_supervisor_plan, run_supervisor
from .monitor import run_cycle
from .ocean import fetch_snapshot
from .report import build_text_report
@ -192,6 +193,22 @@ def cmd_pipeline(args: argparse.Namespace) -> int:
return 0
def cmd_supervise(args: argparse.Namespace) -> int:
config = load_config(Path(args.config) if args.config else None)
if args.status:
with connect() as conn:
init_db(conn)
print(render_lifecycle_status(conn))
return 0
print(render_supervisor_plan())
if not args.yes:
answer = input("> ").strip().lower()
if answer not in {"y", "yes"}:
print("Supervisor cancelled. No action was taken.")
return 0
return run_supervisor(config, once=args.once)
def cmd_experiments(_: argparse.Namespace) -> int:
if not EXPERIMENT_LOG.exists():
print("No experiment log yet. Run ./scripts/ratchet watch 2.")
@ -328,6 +345,13 @@ def build_parser() -> argparse.ArgumentParser:
pipeline.add_argument("--yes", action="store_true", help="accept the printed plan without prompting")
pipeline.set_defaults(func=cmd_pipeline)
supervise = sub.add_parser("supervise", help="run the durable forever lifecycle supervisor")
supervise.add_argument("--config")
supervise.add_argument("--yes", action="store_true", help="start without prompting")
supervise.add_argument("--once", action="store_true", help="run one supervisor decision then stop")
supervise.add_argument("--status", action="store_true", help="print persisted lifecycle status")
supervise.set_defaults(func=cmd_supervise)
experiments = sub.add_parser("experiments", help="print the Karpathy-style experiment log")
experiments.set_defaults(func=cmd_experiments)

View file

@ -0,0 +1,257 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import json
import time
from .config import AppConfig
from .experiments import finish_experiment, start_experiment
from .guidance import POST_WATCH_COOLDOWN_MINUTES, build_operator_cockpit
from .monitor import run_cycle
from .storage import connect, init_db
DEFAULT_WATCH_CYCLES = 24
DEFAULT_INTERVAL_SECONDS = 300
@dataclass(frozen=True)
class LifecycleStatus:
phase: str
next_action_utc: str | None
last_run_id: str | None
message: str
def init_lifecycle_db(conn) -> None:
init_db(conn)
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS lifecycle_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS lifecycle_events (
id INTEGER PRIMARY KEY,
timestamp_utc TEXT NOT NULL,
event_type TEXT NOT NULL,
payload_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS manual_positions (
id INTEGER PRIMARY KEY,
opened_utc TEXT NOT NULL,
closed_utc TEXT,
status TEXT NOT NULL,
venue TEXT NOT NULL,
description TEXT NOT NULL,
expected_maturity_utc TEXT,
payload_json TEXT NOT NULL
);
"""
)
conn.commit()
def get_lifecycle_status(conn) -> LifecycleStatus:
init_lifecycle_db(conn)
state = _read_state(conn)
return LifecycleStatus(
phase=state.get("phase", "idle"),
next_action_utc=state.get("next_action_utc"),
last_run_id=state.get("last_run_id"),
message=state.get("message", "no lifecycle state recorded yet"),
)
def render_lifecycle_status(conn) -> str:
status = get_lifecycle_status(conn)
lines = [
"Braiins Ratchet Lifecycle",
"",
f"Phase: {status.phase}",
f"Next action UTC: {status.next_action_utc or 'now'}",
f"Last run id: {status.last_run_id or 'none'}",
f"Message: {status.message}",
]
if status.next_action_utc:
remaining = _seconds_until(status.next_action_utc)
lines.append(f"Countdown: {_format_duration(remaining)}")
return "\n".join(lines)
def render_supervisor_plan() -> str:
return "\n".join(
[
"Forever Supervisor Proposal",
"",
"I am going to take over the monitor-only autoresearch lifecycle.",
"",
"Loop:",
" 1. Resume persisted lifecycle state from data/ratchet.sqlite.",
" 2. If cooldown is active, wait until the persisted next-action time.",
" 3. Run one 2-hour passive watch when the lifecycle is ready.",
" 4. Write the experiment ledger and run report.",
" 5. Enter post-watch cooldown.",
" 6. Repeat forever until you stop the process.",
"",
"Crash/reboot behavior:",
" Restart ./scripts/ratchet supervise and it resumes from SQLite.",
"",
"Safety:",
" This supervisor is monitor-only.",
" It never places, changes, or cancels Braiins orders.",
" If you manually start a Braiins order, record it separately; this daemon will not infer owner-token state.",
"",
"Are you OK with this? Type yes or no.",
]
)
def run_supervisor(config: AppConfig, *, once: bool = False) -> int:
with connect() as conn:
init_lifecycle_db(conn)
_record_event(conn, "supervisor_started", {"once": once})
while True:
with connect() as conn:
init_lifecycle_db(conn)
state = _read_state(conn)
phase = state.get("phase", "idle")
next_action_utc = state.get("next_action_utc")
if phase == "cooldown" and next_action_utc:
remaining = _seconds_until(next_action_utc)
if remaining > 0:
_print_timer("Lifecycle cooldown", remaining)
if once:
return 0
_sleep_with_progress(remaining)
run_id = _run_watch_stage(config)
next_action = datetime.now(UTC) + timedelta(minutes=POST_WATCH_COOLDOWN_MINUTES)
with connect() as conn:
init_lifecycle_db(conn)
_write_state(
conn,
{
"phase": "cooldown",
"next_action_utc": next_action.isoformat(timespec="seconds"),
"last_run_id": run_id,
"message": "watch complete; cooldown active before next research stage",
},
)
_record_event(
conn,
"watch_completed",
{"run_id": run_id, "next_action_utc": next_action.isoformat(timespec="seconds")},
)
print(build_operator_cockpit(conn))
if once:
return 0
def _run_watch_stage(config: AppConfig) -> str:
experiment = start_experiment(
DEFAULT_WATCH_CYCLES,
DEFAULT_INTERVAL_SECONDS,
"forever supervisor: bounded passive watch stage",
)
with connect() as conn:
init_lifecycle_db(conn)
_write_state(
conn,
{
"phase": "watching",
"next_action_utc": "",
"last_run_id": experiment.run_id,
"message": "2-hour passive watch is running",
},
)
_record_event(conn, "watch_started", {"run_id": experiment.run_id})
status = "completed"
try:
for index in range(DEFAULT_WATCH_CYCLES):
result = run_cycle(conn, config)
print(
f"cycle {index + 1}/{DEFAULT_WATCH_CYCLES}: "
f"{result.proposal.action} - {result.proposal.reason}",
flush=True,
)
if index + 1 < DEFAULT_WATCH_CYCLES:
time.sleep(DEFAULT_INTERVAL_SECONDS)
except KeyboardInterrupt:
status = "interrupted"
print("interrupted: writing partial experiment report before exit", flush=True)
report_path = finish_experiment(
conn,
experiment.run_id,
experiment.started_utc,
DEFAULT_WATCH_CYCLES,
DEFAULT_INTERVAL_SECONDS,
"forever supervisor: bounded passive watch stage",
status=status,
)
_record_event(conn, "watch_report_written", {"run_id": experiment.run_id, "report": report_path})
return experiment.run_id
def _read_state(conn) -> dict[str, str]:
rows = conn.execute("SELECT key, value FROM lifecycle_state").fetchall()
return {row[0]: row[1] for row in rows}
def _write_state(conn, values: dict[str, str]) -> None:
conn.execute("DELETE FROM lifecycle_state")
for key, value in values.items():
conn.execute(
"INSERT INTO lifecycle_state (key, value) VALUES (?, ?)",
(key, value),
)
conn.commit()
def _record_event(conn, event_type: str, payload: dict[str, object]) -> None:
conn.execute(
"""
INSERT INTO lifecycle_events (timestamp_utc, event_type, payload_json)
VALUES (?, ?, ?)
""",
(datetime.now(UTC).isoformat(timespec="seconds"), event_type, json.dumps(payload, sort_keys=True)),
)
conn.commit()
def _seconds_until(timestamp_utc: str) -> int:
try:
target = datetime.fromisoformat(timestamp_utc.replace("Z", "+00:00"))
except ValueError:
return 0
if target.tzinfo is None:
target = target.replace(tzinfo=UTC)
return max(0, int((target.astimezone(UTC) - datetime.now(UTC)).total_seconds()))
def _sleep_with_progress(seconds: int) -> None:
remaining = max(0, seconds)
while remaining > 0:
sleep_for = min(60, remaining)
time.sleep(sleep_for)
remaining -= sleep_for
_print_timer("Lifecycle cooldown", remaining)
def _print_timer(label: str, seconds: int) -> None:
print(f"{label}: {_format_duration(seconds)} remaining", flush=True)
def _format_duration(seconds: int) -> str:
seconds = max(0, seconds)
hours, remainder = divmod(seconds, 3600)
minutes, _ = divmod(remainder, 60)
if hours:
return f"{hours}h {minutes}m"
return f"{minutes}m"

View file

@ -63,6 +63,29 @@ def init_db(conn: sqlite3.Connection) -> None:
score_btc TEXT NOT NULL,
maturity_note TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS lifecycle_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS lifecycle_events (
id INTEGER PRIMARY KEY,
timestamp_utc TEXT NOT NULL,
event_type TEXT NOT NULL,
payload_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS manual_positions (
id INTEGER PRIMARY KEY,
opened_utc TEXT NOT NULL,
closed_utc TEXT,
status TEXT NOT NULL,
venue TEXT NOT NULL,
description TEXT NOT NULL,
expected_maturity_utc TEXT,
payload_json TEXT NOT NULL
);
"""
)
_ensure_market_columns(conn)

49
tests/test_lifecycle.py Normal file
View file

@ -0,0 +1,49 @@
from datetime import UTC, datetime, timedelta
import sqlite3
import unittest
from braiins_ratchet.lifecycle import (
get_lifecycle_status,
init_lifecycle_db,
render_lifecycle_status,
render_supervisor_plan,
)
class LifecycleTests(unittest.TestCase):
def test_lifecycle_tables_initialize_and_status_defaults(self) -> None:
conn = sqlite3.connect(":memory:")
init_lifecycle_db(conn)
status = get_lifecycle_status(conn)
self.assertEqual(status.phase, "idle")
self.assertIsNone(status.next_action_utc)
self.assertIn("no lifecycle state", status.message)
def test_lifecycle_status_renders_countdown(self) -> None:
conn = sqlite3.connect(":memory:")
init_lifecycle_db(conn)
next_action = datetime.now(UTC) + timedelta(minutes=5)
conn.execute("INSERT INTO lifecycle_state (key, value) VALUES (?, ?)", ("phase", "cooldown"))
conn.execute(
"INSERT INTO lifecycle_state (key, value) VALUES (?, ?)",
("next_action_utc", next_action.isoformat(timespec="seconds")),
)
conn.commit()
text = render_lifecycle_status(conn)
self.assertIn("Phase: cooldown", text)
self.assertIn("Countdown:", text)
def test_supervisor_plan_states_monitor_only_resume_contract(self) -> None:
text = render_supervisor_plan()
self.assertIn("Resume persisted lifecycle state", text)
self.assertIn("Restart ./scripts/ratchet supervise", text)
self.assertIn("never places", text)
if __name__ == "__main__":
unittest.main()