mirror of
https://github.com/saymrwulf/BraiinsRatchet.git
synced 2026-05-14 20:37:52 +00:00
Add durable lifecycle supervisor and mac app
This commit is contained in:
parent
27f27992c3
commit
d8e8113c59
13 changed files with 653 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,3 +8,4 @@ data/*.sqlite-shm
|
||||||
data/*.sqlite-wal
|
data/*.sqlite-wal
|
||||||
data/raw/
|
data/raw/
|
||||||
*.log
|
*.log
|
||||||
|
macos/BraiinsRatchet/.build/
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -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.
|
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:
|
For a 6-hour monitoring session:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,48 @@ It only runs after you type `yes`.
|
||||||
|
|
||||||
It is still monitor-only. It never places, changes, or cancels Braiins orders.
|
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
|
## Research Pathway
|
||||||
|
|
||||||
The cockpit has two different time horizons:
|
The cockpit has two different time horizons:
|
||||||
|
|
|
||||||
|
|
@ -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
|
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`
|
## `init-db`
|
||||||
|
|
||||||
Creates `data/ratchet.sqlite` if it does not exist.
|
Creates `data/ratchet.sqlite` if it does not exist.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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:
|
If a run already happened before automatic bookkeeping was available, reconstruct it from the stored SQLite snapshots:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
18
macos/BraiinsRatchet/Package.swift
Normal file
18
macos/BraiinsRatchet/Package.swift
Normal 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"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
23
macos/BraiinsRatchet/README.md
Normal file
23
macos/BraiinsRatchet/README.md
Normal 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.
|
||||||
|
|
@ -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: "'\\''") + "'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ Commands:
|
||||||
once Fetch one fresh sample, then print the cockpit.
|
once Fetch one fresh sample, then print the cockpit.
|
||||||
watch [hours] Run repeated monitor cycles for N hours. Default: 6.
|
watch [hours] Run repeated monitor cycles for N hours. Default: 6.
|
||||||
pipeline Propose timed automation, then ask yes/no.
|
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.
|
report Print the latest stored report without fetching new data.
|
||||||
experiments Print the Karpathy-style experiment ledger.
|
experiments Print the Karpathy-style experiment ledger.
|
||||||
retro SINCE [UNTIL] Write a retroactive report from stored snapshots.
|
retro SINCE [UNTIL] Write a retroactive report from stored snapshots.
|
||||||
|
|
@ -28,6 +29,7 @@ Examples:
|
||||||
./scripts/ratchet once
|
./scripts/ratchet once
|
||||||
./scripts/ratchet watch 6
|
./scripts/ratchet watch 6
|
||||||
./scripts/ratchet pipeline
|
./scripts/ratchet pipeline
|
||||||
|
./scripts/ratchet supervise
|
||||||
./scripts/ratchet report
|
./scripts/ratchet report
|
||||||
./scripts/ratchet experiments
|
./scripts/ratchet experiments
|
||||||
./scripts/ratchet retro 2026-04-25T19:08:00+00:00 2026-04-25T21:05:00+00:00
|
./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 "$@"
|
run_python -m braiins_ratchet.cli pipeline "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd_supervise() {
|
||||||
|
run_python -m braiins_ratchet.cli supervise "$@"
|
||||||
|
}
|
||||||
|
|
||||||
cmd_experiments() {
|
cmd_experiments() {
|
||||||
run_python -m braiins_ratchet.cli experiments
|
run_python -m braiins_ratchet.cli experiments
|
||||||
}
|
}
|
||||||
|
|
@ -152,6 +158,7 @@ main() {
|
||||||
once) cmd_once "$@" ;;
|
once) cmd_once "$@" ;;
|
||||||
watch) cmd_watch "$@" ;;
|
watch) cmd_watch "$@" ;;
|
||||||
pipeline|auto) cmd_pipeline "$@" ;;
|
pipeline|auto) cmd_pipeline "$@" ;;
|
||||||
|
supervise|daemon) cmd_supervise "$@" ;;
|
||||||
report) cmd_report "$@" ;;
|
report) cmd_report "$@" ;;
|
||||||
experiments) cmd_experiments "$@" ;;
|
experiments) cmd_experiments "$@" ;;
|
||||||
retro) cmd_retro "$@" ;;
|
retro) cmd_retro "$@" ;;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from .experiments import (
|
||||||
write_retro_report,
|
write_retro_report,
|
||||||
)
|
)
|
||||||
from .guidance import build_operator_cockpit
|
from .guidance import build_operator_cockpit
|
||||||
|
from .lifecycle import render_lifecycle_status, render_supervisor_plan, run_supervisor
|
||||||
from .monitor import run_cycle
|
from .monitor import run_cycle
|
||||||
from .ocean import fetch_snapshot
|
from .ocean import fetch_snapshot
|
||||||
from .report import build_text_report
|
from .report import build_text_report
|
||||||
|
|
@ -192,6 +193,22 @@ def cmd_pipeline(args: argparse.Namespace) -> int:
|
||||||
return 0
|
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:
|
def cmd_experiments(_: argparse.Namespace) -> int:
|
||||||
if not EXPERIMENT_LOG.exists():
|
if not EXPERIMENT_LOG.exists():
|
||||||
print("No experiment log yet. Run ./scripts/ratchet watch 2.")
|
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.add_argument("--yes", action="store_true", help="accept the printed plan without prompting")
|
||||||
pipeline.set_defaults(func=cmd_pipeline)
|
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 = sub.add_parser("experiments", help="print the Karpathy-style experiment log")
|
||||||
experiments.set_defaults(func=cmd_experiments)
|
experiments.set_defaults(func=cmd_experiments)
|
||||||
|
|
||||||
|
|
|
||||||
257
src/braiins_ratchet/lifecycle.py
Normal file
257
src/braiins_ratchet/lifecycle.py
Normal 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"
|
||||||
|
|
@ -63,6 +63,29 @@ def init_db(conn: sqlite3.Connection) -> None:
|
||||||
score_btc TEXT NOT NULL,
|
score_btc TEXT NOT NULL,
|
||||||
maturity_note 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)
|
_ensure_market_columns(conn)
|
||||||
|
|
|
||||||
49
tests/test_lifecycle.py
Normal file
49
tests/test_lifecycle.py
Normal 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()
|
||||||
Loading…
Reference in a new issue