mirror of
https://github.com/saymrwulf/BraiinsRatchet.git
synced 2026-05-14 20:37:52 +00:00
Rebuild native autoresearch cockpit
This commit is contained in:
parent
9704ac8452
commit
9750530b63
13 changed files with 2011 additions and 1070 deletions
12
README.md
12
README.md
|
|
@ -22,6 +22,8 @@ This builds and opens the native macOS control room. Use the app for normal oper
|
|||
|
||||
The lifecycle state persists in `data/ratchet.sqlite`. If the app or Mac restarts, open the app again and it reads the same state.
|
||||
|
||||
Inside the app, the preferred non-babysitting path is `Start Forever Engine`. It starts the monitor-only lifecycle engine in the background, writes logs under `logs/`, persists state under `data/`, and never places Braiins orders.
|
||||
|
||||
When you manually place a Braiins bid, record the exposure so the supervisor blocks new experiments:
|
||||
|
||||
```bash
|
||||
|
|
@ -42,7 +44,7 @@ For the native macOS app:
|
|||
|
||||
This builds `macos/build/Braiins Ratchet.app` and opens the real app bundle. Do not use `swift run` for normal operation.
|
||||
|
||||
The app is a native visual control room: Mission Control, Research Map, Manual Exposure ledger, Advanced diagnostics, and a Ratchet Lecture. The design rationale is in `docs/APP_DESIGN_RESEARCH.md`.
|
||||
The app is a native visual control room: Mission Control, Mining Stack, Ratchet, Strategy Lab, Manual Exposure, and Evidence Vault. The design rationale is in `docs/APP_DESIGN_RESEARCH.md`.
|
||||
|
||||
Advanced fallback for a 6-hour CLI monitoring session:
|
||||
|
||||
|
|
@ -50,6 +52,14 @@ Advanced fallback for a 6-hour CLI monitoring session:
|
|||
./scripts/ratchet watch 6
|
||||
```
|
||||
|
||||
Advanced fallback for the background monitor engine:
|
||||
|
||||
```bash
|
||||
./scripts/ratchet engine status
|
||||
./scripts/ratchet engine start
|
||||
./scripts/ratchet engine stop
|
||||
```
|
||||
|
||||
Every completed watch is now treated as a ratchet experiment. It writes a run report under `reports/run-*.md` and appends the master ledger at `reports/EXPERIMENT_LOG.md`.
|
||||
|
||||
To inspect the experiment ledger:
|
||||
|
|
|
|||
|
|
@ -15,23 +15,24 @@ Your job is not to understand every metric.
|
|||
Your job is:
|
||||
|
||||
1. Open the app with `./scripts/ratchet app`.
|
||||
2. Stay on `Mission Control` unless you intentionally need raw diagnostics.
|
||||
2. Stay on `Mission Control` unless you intentionally need another tab.
|
||||
3. Read `Current Decision` first.
|
||||
4. Read `Who Is In Control` second.
|
||||
5. Use `Next Passive Action` only when it is enabled.
|
||||
4. Read `Who Owns Control` second.
|
||||
5. Prefer `Start Forever Engine` when you want the app to keep the monitor-only lifecycle moving without babysitting.
|
||||
6. If you manually place a Braiins canary, record it in `Manual Exposure` immediately.
|
||||
|
||||
Do not start extra terminal watches while the app says a watch, cooldown, or manual exposure owns control.
|
||||
|
||||
## Who Is In Control?
|
||||
## Who Owns Control?
|
||||
|
||||
The app has one ownership model:
|
||||
|
||||
1. `The app is ready`: you may start the enabled passive action.
|
||||
2. `A watch run owns control`: leave it alone until it finishes.
|
||||
3. `Cooldown owns control`: wait until the shown earliest action time.
|
||||
4. `Manual exposure owns control`: supervise the real-world Braiins/OCEAN position and do not start new experiments.
|
||||
5. `The app is busy`: a monitor-only backend operation is running right now.
|
||||
2. `Forever engine`: the background monitor engine owns passive sampling; leave it alone.
|
||||
3. `A watch run owns control`: leave it alone until it finishes.
|
||||
4. `Cooldown owns control`: wait until the shown earliest action time.
|
||||
5. `Manual exposure owns control`: supervise the real-world Braiins/OCEAN position and do not start new experiments.
|
||||
6. `The app is busy`: a monitor-only backend operation is running right now.
|
||||
|
||||
This is the anti-babysitting rule: if the app says something else owns control, your workload is zero unless you are supervising a real manual exposure.
|
||||
|
||||
|
|
@ -46,7 +47,8 @@ It can:
|
|||
3. Run passive watch-only research windows.
|
||||
4. Write run reports under `reports/`.
|
||||
5. Track manually executed Braiins exposure that you enter yourself.
|
||||
6. Resume from the same SQLite state after a crash or reboot.
|
||||
6. Start or stop a repo-local forever monitor engine.
|
||||
7. Resume from the same SQLite state after a crash or reboot.
|
||||
|
||||
## Native Mac App
|
||||
|
||||
|
|
@ -66,11 +68,12 @@ This creates `macos/build/Braiins Ratchet.app`. After that, you can open that ap
|
|||
|
||||
The app is organized as:
|
||||
|
||||
1. `Mission Control`: current decision, control ownership, next passive action, progress, evidence, and plain English interpretation.
|
||||
2. `Research Map`: visual autoresearch stage model.
|
||||
3. `Manual Exposure`: record or close manually executed Braiins exposure.
|
||||
4. `Advanced`: raw cockpit, report, and ledger artifacts for diagnostics.
|
||||
5. `Ratchet Lecture`: the general observe, hypothesize, bound, mature, adapt method.
|
||||
1. `Mission Control`: current decision, control ownership, forever engine controls, cooldown progress, and evidence.
|
||||
2. `Mining Stack`: the Umbrel, Knots, Datum, OCEAN, and Braiins interplay.
|
||||
3. `Ratchet`: the observe, price, watch, mature, adapt learning loop.
|
||||
4. `Strategy Lab`: shadow order, expected net, breakeven, and loss boundary.
|
||||
5. `Manual Exposure`: record or close manually executed Braiins exposure.
|
||||
6. `Evidence Vault`: raw cockpit, report, and ledger artifacts for diagnostics.
|
||||
|
||||
## Research Pathway
|
||||
|
||||
|
|
@ -104,7 +107,7 @@ Each completed watch creates one run report:
|
|||
reports/run-*.md
|
||||
```
|
||||
|
||||
Use the app's `Advanced` tab when you need raw artifacts. Mission Control intentionally hides raw logs during normal operation.
|
||||
Use the app's `Evidence Vault` tab when you need raw artifacts. Mission Control intentionally hides raw logs during normal operation.
|
||||
|
||||
## Advanced Fallback Commands
|
||||
|
||||
|
|
@ -115,6 +118,9 @@ Use these only if the native app cannot be opened or you are debugging:
|
|||
./scripts/ratchet once
|
||||
./scripts/ratchet watch 2
|
||||
./scripts/ratchet supervise
|
||||
./scripts/ratchet engine status
|
||||
./scripts/ratchet engine start
|
||||
./scripts/ratchet engine stop
|
||||
./scripts/ratchet position list
|
||||
./scripts/ratchet report
|
||||
./scripts/ratchet experiments
|
||||
|
|
|
|||
|
|
@ -11,9 +11,14 @@ Apple's Liquid Glass guidance emphasizes system-native structure before visual e
|
|||
- Avoid overusing custom glass effects; too much glass becomes noise.
|
||||
- Support arbitrary window sizes with split views.
|
||||
- Preserve accessibility when transparency or motion is reduced.
|
||||
- Use custom glass sparingly; standard controls and split views should carry most of the Tahoe look.
|
||||
|
||||
Source: <https://developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass>
|
||||
|
||||
Apple's SwiftUI Liquid Glass documentation adds the implementation constraint: too many custom glass containers can degrade performance, so this app keeps the primary glass treatment on the hero and control surfaces instead of turning every content block into an effect demo.
|
||||
|
||||
Source: <https://developer.apple.com/documentation/swiftui/applying-liquid-glass-to-custom-views>
|
||||
|
||||
Microsoft's Human-AI Interaction Guidelines are directly relevant because this app makes recommendations under uncertainty:
|
||||
|
||||
- Make clear what the system can and cannot do.
|
||||
|
|
@ -40,16 +45,19 @@ The native app now treats the Python engine as a structured state provider, not
|
|||
|
||||
- Current operator state.
|
||||
- Passive action plan.
|
||||
- Forever engine status.
|
||||
- Guardrail and strategy configuration.
|
||||
- Cockpit text for audit/debug.
|
||||
- Latest OCEAN, Braiins, and strategy proposal payloads.
|
||||
|
||||
The SwiftUI app turns that into native surfaces:
|
||||
|
||||
- `Mission Control`: one exact action, cooldown, direct watch-only controls, and metrics.
|
||||
- `Research Map`: the ratchet pathway as a visual stage model.
|
||||
- `Mission Control`: current decision, control ownership, forever engine, cooldown, and evidence.
|
||||
- `Mining Stack`: Umbrel, Knots, Datum, OCEAN, Braiins, and block-luck interplay.
|
||||
- `Ratchet`: the observe, price, watch, mature, adapt pathway.
|
||||
- `Strategy Lab`: shadow order, expected net, breakeven, and loss boundary.
|
||||
- `Manual Exposure`: the ledger for real manually placed Braiins exposure.
|
||||
- `Advanced`: raw artifacts kept available but no longer primary.
|
||||
- `Ratchet Lecture`: a teachable model of observe, hypothesize, bound, mature, adapt.
|
||||
- `Evidence Vault`: raw artifacts kept available but no longer primary.
|
||||
|
||||
## The Ratchet UX Rule
|
||||
|
||||
|
|
@ -60,5 +68,6 @@ The app must always answer these questions without forcing the user to parse log
|
|||
3. What evidence artifact exists?
|
||||
4. What action is blocked for safety?
|
||||
5. Which single knob, if any, is eligible for later adaptation?
|
||||
6. How the Braiins/OCEAN/Umbrel/Datum/Knots system interacts with the recommendation.
|
||||
|
||||
If the app cannot answer those questions graphically and in plain language, it is failing its purpose.
|
||||
|
|
|
|||
|
|
@ -15,14 +15,15 @@ This builds `macos/build/Braiins Ratchet.app` and opens the packaged app. Use th
|
|||
## Current Scope
|
||||
|
||||
- Native macOS SwiftUI control room.
|
||||
- Mission Control with one explicit next action.
|
||||
- Research Map with the full ratchet pathway.
|
||||
- Direct watch-only controls without an approval gate.
|
||||
- Mission Control with one explicit decision and control owner.
|
||||
- Mining Stack view for Umbrel, Knots, Datum, OCEAN, and Braiins interplay.
|
||||
- Ratchet view for the full autoresearch pathway.
|
||||
- Strategy Lab for shadow orders and loss boundaries.
|
||||
- Forever Engine controls for the monitor-only background lifecycle.
|
||||
- Manual exposure recording and closing controls.
|
||||
- Advanced panel for raw artifacts and backend diagnostics.
|
||||
- Ratchet Lecture for the general autoresearch method.
|
||||
- Evidence Vault for raw artifacts and backend diagnostics.
|
||||
- Monitor-only. It never places Braiins orders.
|
||||
|
||||
## Product Direction
|
||||
|
||||
The next production step is wiring LaunchAgent controls for the durable supervisor while keeping Mission Control domain-first.
|
||||
The next production step is optional LaunchAgent integration. The current app already starts and stops a repo-local background monitor engine.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,8 +13,8 @@ Commands:
|
|||
setup Create the local .venv and initialize the local database.
|
||||
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.
|
||||
engine Start/stop/status for the background monitor engine.
|
||||
app Build and open the native macOS app.
|
||||
position Record/list/close manually executed Braiins exposure.
|
||||
report Print the latest stored report without fetching new data.
|
||||
|
|
@ -31,8 +31,8 @@ Examples:
|
|||
./scripts/ratchet setup
|
||||
./scripts/ratchet once
|
||||
./scripts/ratchet watch 6
|
||||
./scripts/ratchet pipeline
|
||||
./scripts/ratchet supervise
|
||||
./scripts/ratchet engine status
|
||||
./scripts/ratchet app
|
||||
./scripts/ratchet position list
|
||||
./scripts/ratchet report
|
||||
|
|
@ -126,6 +126,10 @@ cmd_supervise() {
|
|||
run_python -m braiins_ratchet.cli supervise "$@"
|
||||
}
|
||||
|
||||
cmd_engine() {
|
||||
run_python -m braiins_ratchet.cli engine "$@"
|
||||
}
|
||||
|
||||
cmd_app() {
|
||||
local app_path
|
||||
app_path="$("$ROOT_DIR/scripts/build_mac_app" | tail -n 1)"
|
||||
|
|
@ -179,6 +183,7 @@ main() {
|
|||
watch) cmd_watch "$@" ;;
|
||||
pipeline|auto) cmd_pipeline "$@" ;;
|
||||
supervise|daemon) cmd_supervise "$@" ;;
|
||||
engine) cmd_engine "$@" ;;
|
||||
app|mac-app) cmd_app "$@" ;;
|
||||
position|positions) cmd_position "$@" ;;
|
||||
report) cmd_report "$@" ;;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from .experiments import (
|
|||
summarize_since,
|
||||
write_retro_report,
|
||||
)
|
||||
from .engine import get_engine_status, render_engine_status, start_engine, stop_engine
|
||||
from .guidance import build_operator_cockpit, get_operator_state
|
||||
from .lifecycle import (
|
||||
close_manual_position,
|
||||
|
|
@ -160,6 +161,7 @@ def cmd_next(_: argparse.Namespace) -> int:
|
|||
|
||||
|
||||
def cmd_app_state(_: argparse.Namespace) -> int:
|
||||
config = load_config(None)
|
||||
with connect() as conn:
|
||||
init_db(conn)
|
||||
operator_state = get_operator_state(conn)
|
||||
|
|
@ -168,11 +170,13 @@ def cmd_app_state(_: argparse.Namespace) -> int:
|
|||
"generated_at": datetime.now(UTC).isoformat(timespec="seconds"),
|
||||
"operator_state": asdict(operator_state),
|
||||
"automation_plan": asdict(automation_plan),
|
||||
"engine_status": asdict(get_engine_status()),
|
||||
"config": asdict(config),
|
||||
"cockpit": build_operator_cockpit(conn),
|
||||
"latest": {
|
||||
"ocean": _object_dict(latest_ocean_snapshot(conn)),
|
||||
"market": _object_dict(latest_market_snapshot(conn)),
|
||||
"proposal": _object_dict(latest_proposal(conn)),
|
||||
"proposal": _proposal_dict(latest_proposal(conn)),
|
||||
},
|
||||
}
|
||||
print(json.dumps(payload, default=str, indent=2))
|
||||
|
|
@ -330,6 +334,17 @@ def cmd_guardrails(args: argparse.Namespace) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def cmd_engine(args: argparse.Namespace) -> int:
|
||||
if args.engine_command == "start":
|
||||
status = start_engine()
|
||||
elif args.engine_command == "stop":
|
||||
status = stop_engine()
|
||||
else:
|
||||
status = get_engine_status()
|
||||
print(render_engine_status(status))
|
||||
return 0
|
||||
|
||||
|
||||
def _proposal_json(proposal: object) -> str:
|
||||
def default(value: object) -> object:
|
||||
if hasattr(value, "__dict__"):
|
||||
|
|
@ -345,6 +360,22 @@ def _object_dict(value: object | None) -> dict[str, object] | None:
|
|||
return dict(value.__dict__) if hasattr(value, "__dict__") else {"value": str(value)}
|
||||
|
||||
|
||||
def _proposal_dict(value: object | None) -> dict[str, object] | None:
|
||||
if value is None or not hasattr(value, "__dict__"):
|
||||
return None
|
||||
payload = dict(value.__dict__)
|
||||
order = payload.get("order")
|
||||
if order is not None and hasattr(order, "__dict__"):
|
||||
order_payload = dict(order.__dict__)
|
||||
payload["order"] = order_payload
|
||||
payload["order_price_btc_per_eh_day"] = order_payload.get("price_btc_per_eh_day")
|
||||
payload["order_spend_btc"] = order_payload.get("spend_btc")
|
||||
payload["order_duration_minutes"] = order_payload.get("duration_minutes")
|
||||
payload["order_objective"] = order_payload.get("objective")
|
||||
payload["order_implied_hashrate_eh_s"] = order.implied_hashrate_eh_s
|
||||
return payload
|
||||
|
||||
|
||||
def _run_one_fresh_cycle(config: object) -> None:
|
||||
with connect() as conn:
|
||||
run_cycle(conn, config)
|
||||
|
|
@ -463,6 +494,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
guardrails.add_argument("--config")
|
||||
guardrails.set_defaults(func=cmd_guardrails)
|
||||
|
||||
engine = sub.add_parser("engine", help="manage the monitor-only forever engine")
|
||||
engine_sub = engine.add_subparsers(dest="engine_command")
|
||||
engine_sub.add_parser("status", help="show engine status")
|
||||
engine_sub.add_parser("start", help="start the monitor-only forever engine")
|
||||
engine_sub.add_parser("stop", help="stop the monitor-only forever engine")
|
||||
engine.set_defaults(func=cmd_engine, engine_command="status")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
|
|
|
|||
200
src/braiins_ratchet/engine.py
Normal file
200
src/braiins_ratchet/engine.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from .config import REPO_ROOT
|
||||
from .storage import DATA_DIR
|
||||
|
||||
|
||||
LOG_DIR = REPO_ROOT / "logs"
|
||||
SUPERVISOR_LOG = LOG_DIR / "supervisor.log"
|
||||
SUPERVISOR_PID = DATA_DIR / "supervisor.pid"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineStatus:
|
||||
running: bool
|
||||
pid: int | None
|
||||
detail: str
|
||||
log_path: str
|
||||
|
||||
|
||||
def get_engine_status() -> EngineStatus:
|
||||
pid = _pid_from_file()
|
||||
if pid is not None and _pid_matches_supervisor(pid):
|
||||
return EngineStatus(
|
||||
running=True,
|
||||
pid=pid,
|
||||
detail=f"forever monitor engine is running as pid {pid}",
|
||||
log_path=str(SUPERVISOR_LOG.relative_to(REPO_ROOT)),
|
||||
)
|
||||
|
||||
discovered = _find_supervisor_pid()
|
||||
if discovered is not None:
|
||||
SUPERVISOR_PID.parent.mkdir(parents=True, exist_ok=True)
|
||||
SUPERVISOR_PID.write_text(str(discovered), encoding="utf-8")
|
||||
return EngineStatus(
|
||||
running=True,
|
||||
pid=discovered,
|
||||
detail=f"forever monitor engine is running as pid {discovered}",
|
||||
log_path=str(SUPERVISOR_LOG.relative_to(REPO_ROOT)),
|
||||
)
|
||||
|
||||
if pid is not None:
|
||||
_clear_pid_file()
|
||||
return EngineStatus(
|
||||
running=False,
|
||||
pid=None,
|
||||
detail="forever monitor engine is not running",
|
||||
log_path=str(SUPERVISOR_LOG.relative_to(REPO_ROOT)),
|
||||
)
|
||||
|
||||
|
||||
def start_engine() -> EngineStatus:
|
||||
current = get_engine_status()
|
||||
if current.running:
|
||||
return current
|
||||
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
env = os.environ.copy()
|
||||
src_path = str(REPO_ROOT / "src")
|
||||
env["PYTHONPATH"] = src_path + os.pathsep + env.get("PYTHONPATH", "")
|
||||
|
||||
log_handle = SUPERVISOR_LOG.open("a", encoding="utf-8")
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-m", "braiins_ratchet.cli", "supervise", "--yes"],
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log_handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
log_handle.close()
|
||||
SUPERVISOR_PID.write_text(str(process.pid), encoding="utf-8")
|
||||
return EngineStatus(
|
||||
running=True,
|
||||
pid=process.pid,
|
||||
detail=f"forever monitor engine started as pid {process.pid}",
|
||||
log_path=str(SUPERVISOR_LOG.relative_to(REPO_ROOT)),
|
||||
)
|
||||
|
||||
|
||||
def stop_engine() -> EngineStatus:
|
||||
status = get_engine_status()
|
||||
if not status.running or status.pid is None:
|
||||
_clear_pid_file()
|
||||
return EngineStatus(
|
||||
running=False,
|
||||
pid=None,
|
||||
detail="forever monitor engine was not running",
|
||||
log_path=str(SUPERVISOR_LOG.relative_to(REPO_ROOT)),
|
||||
)
|
||||
|
||||
try:
|
||||
os.kill(status.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
_clear_pid_file()
|
||||
return EngineStatus(
|
||||
running=False,
|
||||
pid=None,
|
||||
detail=f"forever monitor engine pid {status.pid} already exited",
|
||||
log_path=str(SUPERVISOR_LOG.relative_to(REPO_ROOT)),
|
||||
)
|
||||
_clear_pid_file()
|
||||
return EngineStatus(
|
||||
running=False,
|
||||
pid=None,
|
||||
detail=f"sent SIGTERM to forever monitor engine pid {status.pid}",
|
||||
log_path=str(SUPERVISOR_LOG.relative_to(REPO_ROOT)),
|
||||
)
|
||||
|
||||
|
||||
def render_engine_status(status: EngineStatus) -> str:
|
||||
lines = [
|
||||
"Braiins Ratchet Engine",
|
||||
"",
|
||||
f"Running: {'yes' if status.running else 'no'}",
|
||||
f"PID: {status.pid or 'none'}",
|
||||
f"Detail: {status.detail}",
|
||||
f"Log: {status.log_path}",
|
||||
"",
|
||||
"Safety: monitor-only; never places, changes, or cancels Braiins orders.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _pid_from_file() -> int | None:
|
||||
try:
|
||||
text = SUPERVISOR_PID.read_text(encoding="utf-8").strip()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
return int(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _clear_pid_file() -> None:
|
||||
try:
|
||||
SUPERVISOR_PID.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _pid_matches_supervisor(pid: int) -> bool:
|
||||
command = _command_for_pid(pid)
|
||||
return command is not None and _is_supervisor_command(command)
|
||||
|
||||
|
||||
def _command_for_pid(pid: int) -> str | None:
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["ps", "-p", str(pid), "-o", "command="],
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
command = output.strip()
|
||||
return command or None
|
||||
|
||||
|
||||
def _find_supervisor_pid() -> int | None:
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["ps", "-axo", "pid=,command="],
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
|
||||
current_pid = os.getpid()
|
||||
for line in output.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
pid_text, _, command = stripped.partition(" ")
|
||||
try:
|
||||
pid = int(pid_text)
|
||||
except ValueError:
|
||||
continue
|
||||
if pid == current_pid:
|
||||
continue
|
||||
if _is_supervisor_command(command):
|
||||
return pid
|
||||
return None
|
||||
|
||||
|
||||
def _is_supervisor_command(command: str) -> bool:
|
||||
return (
|
||||
"braiins_ratchet.cli supervise" in command
|
||||
or "./scripts/ratchet supervise" in command
|
||||
)
|
||||
|
|
@ -127,7 +127,7 @@ def build_operator_cockpit(conn) -> str:
|
|||
" ./scripts/ratchet next # read this cockpit",
|
||||
" ./scripts/ratchet once # fetch one fresh sample and report",
|
||||
" ./scripts/ratchet watch 2 # run a bounded 2-hour experiment",
|
||||
" ./scripts/ratchet pipeline # propose automation, then ask yes/no",
|
||||
" ./scripts/ratchet engine start # start the monitor-only forever engine",
|
||||
" ./scripts/ratchet experiments # read the experiment ledger",
|
||||
" ./scripts/ratchet report # read the latest raw human report",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import time
|
|||
|
||||
from .config import AppConfig
|
||||
from .experiments import finish_experiment, start_experiment
|
||||
from .guidance import POST_WATCH_COOLDOWN_MINUTES, build_operator_cockpit
|
||||
from .guidance import POST_WATCH_COOLDOWN_MINUTES, build_operator_cockpit, get_operator_state
|
||||
from .monitor import run_cycle
|
||||
from .storage import connect, init_db
|
||||
|
||||
|
|
@ -146,6 +146,13 @@ def run_supervisor(config: AppConfig, *, once: bool = False) -> int:
|
|||
return 0
|
||||
time.sleep(60)
|
||||
continue
|
||||
report_cooldown_seconds = _sync_recent_watch_cooldown(conn)
|
||||
if report_cooldown_seconds > 0:
|
||||
_print_timer("Report cooldown", report_cooldown_seconds)
|
||||
if once:
|
||||
return 0
|
||||
_sleep_with_progress(report_cooldown_seconds)
|
||||
continue
|
||||
state = _read_state(conn)
|
||||
phase = state.get("phase", "idle")
|
||||
next_action_utc = state.get("next_action_utc")
|
||||
|
|
@ -227,6 +234,35 @@ def _run_watch_stage(config: AppConfig) -> str:
|
|||
return experiment.run_id
|
||||
|
||||
|
||||
def _sync_recent_watch_cooldown(conn) -> int:
|
||||
operator_state = get_operator_state(conn)
|
||||
completed = operator_state.completed_watch
|
||||
if completed is None or completed.remaining_minutes <= 0:
|
||||
return 0
|
||||
|
||||
state = _read_state(conn)
|
||||
if state.get("phase") != "cooldown" or state.get("next_action_utc") != completed.earliest_action_utc:
|
||||
_write_state(
|
||||
conn,
|
||||
{
|
||||
"phase": "cooldown",
|
||||
"next_action_utc": completed.earliest_action_utc,
|
||||
"last_run_id": completed.report_path,
|
||||
"message": "recent watch report is cooling down before next research stage",
|
||||
},
|
||||
)
|
||||
_record_event(
|
||||
conn,
|
||||
"cooldown_synced_from_report",
|
||||
{
|
||||
"report": completed.report_path,
|
||||
"next_action_utc": completed.earliest_action_utc,
|
||||
"remaining_minutes": completed.remaining_minutes,
|
||||
},
|
||||
)
|
||||
return completed.remaining_minutes * 60
|
||||
|
||||
|
||||
def open_manual_position(
|
||||
conn,
|
||||
*,
|
||||
|
|
|
|||
106
tests/test_engine.py
Normal file
106
tests/test_engine.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from braiins_ratchet import engine
|
||||
|
||||
|
||||
class EngineStatusTests(unittest.TestCase):
|
||||
def test_engine_status_reports_not_running_without_pid(self) -> None:
|
||||
with _isolated_engine_paths() as paths:
|
||||
with patch.object(engine, "_find_supervisor_pid", return_value=None):
|
||||
status = engine.get_engine_status()
|
||||
|
||||
self.assertFalse(status.running)
|
||||
self.assertIsNone(status.pid)
|
||||
self.assertEqual(status.log_path, "logs/supervisor.log")
|
||||
self.assertFalse(paths["pid"].exists())
|
||||
|
||||
def test_engine_status_clears_stale_pid_file(self) -> None:
|
||||
with _isolated_engine_paths() as paths:
|
||||
paths["pid"].parent.mkdir(parents=True, exist_ok=True)
|
||||
paths["pid"].write_text("999999", encoding="utf-8")
|
||||
with (
|
||||
patch.object(engine, "_pid_matches_supervisor", return_value=False),
|
||||
patch.object(engine, "_find_supervisor_pid", return_value=None),
|
||||
):
|
||||
status = engine.get_engine_status()
|
||||
|
||||
self.assertFalse(status.running)
|
||||
self.assertFalse(paths["pid"].exists())
|
||||
|
||||
def test_engine_status_records_discovered_supervisor_pid(self) -> None:
|
||||
with _isolated_engine_paths() as paths:
|
||||
with patch.object(engine, "_find_supervisor_pid", return_value=12345):
|
||||
status = engine.get_engine_status()
|
||||
|
||||
self.assertTrue(status.running)
|
||||
self.assertEqual(status.pid, 12345)
|
||||
self.assertEqual(paths["pid"].read_text(encoding="utf-8"), "12345")
|
||||
|
||||
def test_render_engine_status_is_noob_readable(self) -> None:
|
||||
text = engine.render_engine_status(
|
||||
engine.EngineStatus(
|
||||
running=True,
|
||||
pid=123,
|
||||
detail="forever monitor engine is running as pid 123",
|
||||
log_path="logs/supervisor.log",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertIn("Running: yes", text)
|
||||
self.assertIn("PID: 123", text)
|
||||
self.assertIn("monitor-only", text)
|
||||
self.assertIn("never places", text)
|
||||
|
||||
def test_stop_engine_handles_process_that_already_exited(self) -> None:
|
||||
with _isolated_engine_paths():
|
||||
with (
|
||||
patch.object(
|
||||
engine,
|
||||
"get_engine_status",
|
||||
return_value=engine.EngineStatus(
|
||||
running=True,
|
||||
pid=123,
|
||||
detail="running",
|
||||
log_path="logs/supervisor.log",
|
||||
),
|
||||
),
|
||||
patch.object(engine.os, "kill", side_effect=ProcessLookupError),
|
||||
):
|
||||
status = engine.stop_engine()
|
||||
|
||||
self.assertFalse(status.running)
|
||||
self.assertIn("already exited", status.detail)
|
||||
|
||||
|
||||
class _isolated_engine_paths:
|
||||
def __enter__(self):
|
||||
self.tmp = TemporaryDirectory()
|
||||
root = Path(self.tmp.name)
|
||||
self.paths = {
|
||||
"root": root,
|
||||
"data": root / "data",
|
||||
"logs": root / "logs",
|
||||
"pid": root / "data" / "supervisor.pid",
|
||||
"log": root / "logs" / "supervisor.log",
|
||||
}
|
||||
self.patcher = patch.multiple(
|
||||
engine,
|
||||
REPO_ROOT=self.paths["root"],
|
||||
DATA_DIR=self.paths["data"],
|
||||
LOG_DIR=self.paths["logs"],
|
||||
SUPERVISOR_PID=self.paths["pid"],
|
||||
SUPERVISOR_LOG=self.paths["log"],
|
||||
)
|
||||
self.patcher.start()
|
||||
return self.paths
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
self.patcher.stop()
|
||||
self.tmp.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
from datetime import UTC, datetime, timedelta
|
||||
import sqlite3
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from braiins_ratchet.lifecycle import (
|
||||
close_manual_position,
|
||||
|
|
@ -11,6 +13,7 @@ from braiins_ratchet.lifecycle import (
|
|||
render_manual_positions,
|
||||
render_lifecycle_status,
|
||||
render_supervisor_plan,
|
||||
_sync_recent_watch_cooldown,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -71,6 +74,25 @@ class LifecycleTests(unittest.TestCase):
|
|||
self.assertEqual(list_manual_positions(conn, status="active"), [])
|
||||
self.assertEqual(get_lifecycle_status(conn).phase, "idle")
|
||||
|
||||
def test_recent_watch_report_synchronizes_supervisor_cooldown(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
init_lifecycle_db(conn)
|
||||
completed_watch = SimpleNamespace(
|
||||
report_path="reports/run-example.md",
|
||||
remaining_minutes=42,
|
||||
earliest_action_utc="2026-04-28T15:41:51+00:00",
|
||||
)
|
||||
operator_state = SimpleNamespace(completed_watch=completed_watch)
|
||||
|
||||
with patch("braiins_ratchet.lifecycle.get_operator_state", return_value=operator_state):
|
||||
wait_seconds = _sync_recent_watch_cooldown(conn)
|
||||
|
||||
status = get_lifecycle_status(conn)
|
||||
self.assertEqual(wait_seconds, 42 * 60)
|
||||
self.assertEqual(status.phase, "cooldown")
|
||||
self.assertEqual(status.next_action_utc, "2026-04-28T15:41:51+00:00")
|
||||
self.assertIn("recent watch report", status.message)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ class MacAppPackagingTest(unittest.TestCase):
|
|||
self.assertIn("app|mac-app", text)
|
||||
self.assertIn("cmd_app", text)
|
||||
self.assertIn("app-state", text)
|
||||
self.assertIn("engine", text)
|
||||
self.assertNotIn("swift run BraiinsRatchetMac", text)
|
||||
|
||||
def test_python_cli_exposes_structured_app_state(self):
|
||||
|
|
@ -22,6 +23,9 @@ class MacAppPackagingTest(unittest.TestCase):
|
|||
|
||||
self.assertEqual(args.func.__name__, "cmd_app_state")
|
||||
|
||||
engine_args = build_parser().parse_args(["engine", "status"])
|
||||
self.assertEqual(engine_args.func.__name__, "cmd_engine")
|
||||
|
||||
def test_mac_app_builder_creates_bundle_contract(self):
|
||||
builder = ROOT / "scripts" / "build_mac_app"
|
||||
text = builder.read_text()
|
||||
|
|
@ -51,7 +55,11 @@ class MacAppPackagingTest(unittest.TestCase):
|
|||
self.assertIn("This project now has one normal operator entry point", text)
|
||||
self.assertIn("./scripts/ratchet app", text)
|
||||
self.assertIn("The app is the control room", text)
|
||||
self.assertIn("Who Is In Control", text)
|
||||
self.assertIn("Who Owns Control", text)
|
||||
self.assertIn("Start Forever Engine", text)
|
||||
self.assertIn("Mining Stack", text)
|
||||
self.assertIn("Strategy Lab", text)
|
||||
self.assertIn("Evidence Vault", text)
|
||||
self.assertNotIn("Controlled Automation", text)
|
||||
self.assertNotIn("./scripts/ratchet pipeline", text)
|
||||
|
||||
|
|
@ -61,14 +69,17 @@ class MacAppPackagingTest(unittest.TestCase):
|
|||
|
||||
self.assertIn("NavigationSplitView", text)
|
||||
self.assertIn("MissionControlView", text)
|
||||
self.assertIn("ResearchTimeline", text)
|
||||
self.assertIn("AutoresearchOrb", text)
|
||||
self.assertIn("MiningStackView", text)
|
||||
self.assertIn("RatchetPathView", text)
|
||||
self.assertIn("StrategyLabView", text)
|
||||
self.assertIn("EvidenceVaultView", text)
|
||||
self.assertIn("AppStatePayload", text)
|
||||
self.assertIn("EngineStatusPayload", text)
|
||||
self.assertIn("loadAppState", text)
|
||||
self.assertIn("PassiveRunCard", text)
|
||||
self.assertIn("ControlOwnershipCard", text)
|
||||
self.assertIn("EvidenceDeck", text)
|
||||
self.assertIn("AdvancedView", text)
|
||||
self.assertIn("Start Forever Engine", text)
|
||||
self.assertIn("Who Owns Control", text)
|
||||
self.assertIn("Mining Stack", text)
|
||||
self.assertIn("Strategy Lab", text)
|
||||
self.assertIn("Current Decision", text)
|
||||
self.assertNotIn("Do This Now", text)
|
||||
self.assertNotIn("Automation Gate", text)
|
||||
|
|
|
|||
Loading…
Reference in a new issue