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/raw/
|
||||
*.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.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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.
|
||||
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 "$@" ;;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
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,
|
||||
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
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