feat(night_mode): close PARTIAL gaps — dichotomy wave
This commit is contained in:
1 parent
527dc7749e
commit
150f5b86cb
8 files changed
+3072998
-3059415
No files matched your search
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live parity for the night_mode dichotomy wave: the Go port
|
||||
(src/go/night-mode) and the Python oracle (rune.night_mode) must produce
|
||||
byte-identical results for the closed gaps, driven from IDENTICAL state
|
||||
files (status/report/stop are pure projections of state; _ctx_budget is
|
||||
covered by the frozen truth battery). started_at strings round-trip
|
||||
unmodified through both engines, so no normalization is needed.
|
||||
|
||||
Usage: python3 scripts/check_night_mode_parity.py
|
||||
Exits non-zero on any divergence.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
GO_DIR = REPO / "src/go/night-mode"
|
||||
|
||||
SCENARIOS = {
|
||||
"idle": {},
|
||||
"running": {
|
||||
"active": True, "started_at": "2026-10-13T03:00:00Z",
|
||||
"duration_hours": 8.0, "current_phase": "iterate", "iterations": 7,
|
||||
"improvements": ["Memory consolidation completed", "Q: q A: a"],
|
||||
"questions": ["Iteration 0: What should I improve next?"],
|
||||
"decisions": [], "errors": ["LLM iteration failed: boom"],
|
||||
},
|
||||
"stopping": {
|
||||
"active": False, "started_at": None, "duration_hours": 4.5,
|
||||
"current_phase": "stopping", "iterations": 2,
|
||||
"improvements": [], "questions": [], "decisions": ["x"], "errors": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def py_status(state_file):
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
import rune.night_mode as nm
|
||||
|
||||
return nm.NightMode(storage_path=str(state_file)).status()
|
||||
|
||||
|
||||
def py_report(state_file):
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
import rune.night_mode as nm
|
||||
|
||||
return nm.NightMode(storage_path=str(state_file)).report()
|
||||
|
||||
|
||||
def py_stop(state_file):
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
import rune.night_mode as nm
|
||||
|
||||
return nm.NightMode(storage_path=str(state_file)).stop()
|
||||
|
||||
|
||||
def go_cli(binary, mem_dir, *args):
|
||||
env = {**os.environ, "RUNE_MEMORY_DIR": str(mem_dir)}
|
||||
p = subprocess.run([str(binary), *args], capture_output=True, text=True, env=env, timeout=30)
|
||||
return json.loads(p.stdout)
|
||||
|
||||
|
||||
def canon(v):
|
||||
return json.loads(json.dumps(v))
|
||||
|
||||
|
||||
def main():
|
||||
subprocess.run(["go", "build", "-o", GO_DIR / "night-mode-bin", "."],
|
||||
cwd=GO_DIR, check=True)
|
||||
binary = GO_DIR / "night-mode-bin"
|
||||
failures = 0
|
||||
|
||||
for name, state in SCENARIOS.items():
|
||||
for op, py_fn in (("status", py_status), ("report", py_report), ("stop", py_stop)):
|
||||
py_dir = Path(tempfile.mkdtemp(prefix=f"nm-py-{name}-"))
|
||||
go_dir = Path(tempfile.mkdtemp(prefix=f"nm-go-{name}-"))
|
||||
for d in (py_dir, go_dir):
|
||||
(d / "night_mode.json").write_text(json.dumps(state))
|
||||
try:
|
||||
expected = py_fn(py_dir / "night_mode.json")
|
||||
actual = go_cli(binary, go_dir, op)
|
||||
if canon(actual) != canon(expected):
|
||||
print(f"DIVERGENCE {name}/{op}:\n py: {canon(expected)}\n go: {canon(actual)}")
|
||||
failures += 1
|
||||
else:
|
||||
print(f"parity ok: {name}/{op}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"ERROR {name}/{op}: {e}")
|
||||
failures += 1
|
||||
|
||||
# delegation live: full CLI round-trip start→status→stop→report
|
||||
live = Path(tempfile.mkdtemp(prefix="nm-live-"))
|
||||
env = {**os.environ, "RUNE_MEMORY_DIR": str(live)}
|
||||
seq = [
|
||||
(["status"], lambda out: out["active"] is False and out["phase"] == "idle"),
|
||||
(["start", "4"], lambda out: out["success"] is True
|
||||
and out["state"]["duration_hours"] == 4.0),
|
||||
(["status"], lambda out: out["active"] is True),
|
||||
(["stop"], lambda out: out["success"] is True
|
||||
and out["state"]["current_phase"] == "stopping"),
|
||||
(["stop"], lambda out: out["success"] is False
|
||||
and out["error"] == "Night mode not active"),
|
||||
(["report"], lambda out: "0 iterations" in out["summary"]),
|
||||
]
|
||||
for argv, check in seq:
|
||||
out = go_cli(binary, live, *argv)
|
||||
if not check(out):
|
||||
print(f"LIVE DIVERGENCE {argv}: {out}")
|
||||
failures += 1
|
||||
state_on_disk = json.loads((live / "night_mode.json").read_text())
|
||||
if state_on_disk["active"] or state_on_disk["current_phase"] != "stopping":
|
||||
print(f"LIVE DIVERGENCE state file: {state_on_disk}")
|
||||
failures += 1
|
||||
else:
|
||||
print("parity ok: live CLI round-trip (delegation live)")
|
||||
|
||||
(GO_DIR / "night-mode-bin").unlink(missing_ok=True)
|
||||
if failures:
|
||||
print(f"FAIL: {failures} divergences")
|
||||
return 1
|
||||
print("LIVE PARITY 100%: status/report/stop × 3 scenarios + CLI round-trip")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""gen_night_mode_truth.py — factory wave truth battery for night_mode.
|
||||
|
||||
Exercises the module-level public surface of src/rune/night_mode.py whose
|
||||
results are clock- and state-file-invariant, freezing the oracle's behavior
|
||||
so the Go port at src/go/night-mode replays it byte-for-byte:
|
||||
|
||||
* _ctx_budget — every profile branch (T0 window-0, T1/T2/T3 local,
|
||||
T4 non-local tool reserve, unknown-model fallback)
|
||||
* stop_night_mode — idle reverse guard (error branch)
|
||||
* get_status — idle status shape
|
||||
* get_report — idle report shape
|
||||
|
||||
The stateful lifecycle (start/stop/status/report/to_dict/_load/_save/
|
||||
_run_loop/_run_sleep/_run_evaluation) is IO-bound (clock, storage, threads,
|
||||
Ollama HTTP): it is pinned by the Go unit battery (night_mode_test.go,
|
||||
injected clock/runner/HTTP seams) and tests/test_night_mode.py, NOT by this
|
||||
file — freeze_truth.py --verify re-runs cases against a fresh module with
|
||||
the real clock, so only clock-free results belong here.
|
||||
|
||||
Verify (byte-identical law):
|
||||
RUNE_MEMORY_DIR=$(mktemp -d) PYTHONPATH=src \\
|
||||
python3 scripts/freeze_truth.py --verify \\
|
||||
src/go/night-mode/frozen_truth.json --module rune.night_mode
|
||||
(RUNE_MEMORY_DIR must point at a directory with no night_mode.json — the
|
||||
idle-state cases construct the global NightMode from it.)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
os.environ["RUNE_MEMORY_DIR"] = tempfile.mkdtemp(prefix="nm-truth-")
|
||||
os.environ["RUNE_NIGHT_MODE_CORE"] = "python"
|
||||
|
||||
from freeze_truth import freeze, write # noqa: E402
|
||||
|
||||
import rune.night_mode as nm # noqa: E402
|
||||
|
||||
|
||||
def main():
|
||||
cases = [
|
||||
# stop: idle reverse guard (error branch — deterministic)
|
||||
("stop_night_mode", (), {}),
|
||||
# status/report: idle shapes (started_at None, defaults)
|
||||
("get_status", (), {}),
|
||||
("get_report", (), {}),
|
||||
# _ctx_budget: every resolve branch of model_registry.DEFAULT_PROFILES
|
||||
("_ctx_budget", ("qwen2.5:3b",), {}), # T1 local, window 32768
|
||||
("_ctx_budget", ("qwen3:14b",), {}), # T2 local, window 32768
|
||||
("_ctx_budget", ("qwen3.6:27b",), {}), # T3 local, window 32768
|
||||
("_ctx_budget", ("mimo-v2.5-pro",), {}), # T4 non-local + tool reserve
|
||||
("_ctx_budget", ("deterministic",), {}), # T0 window 0 -> or-8192
|
||||
("_ctx_budget", ("unknown-model",), {}), # missing -> fallback 8192
|
||||
]
|
||||
truth = freeze("rune.night_mode", cases, oracle_module="rune.night_mode")
|
||||
dest = REPO / "src/go/night-mode/frozen_truth.json"
|
||||
write(truth, dest)
|
||||
print(f"wrote {len(truth)} cases → {dest}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Open the #2479 dichotomy-wave construction contract for night_mode.
|
||||
Full lifecycle in ONE process (shared-store clobber hazard)."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from rune.construction_interface import ConstructionInterface
|
||||
|
||||
iface = ConstructionInterface(builder="factory-gap-nm")
|
||||
|
||||
r = iface.submit_need(
|
||||
"Close the night_mode dichotomy PARTIAL gaps (audit-named): Go port "
|
||||
"of _ctx_budget, get_night_mode, start_night_mode, stop_night_mode, "
|
||||
"get_status, get_report, to_dict, _load, _save, _run_loop, _run_sleep, "
|
||||
"_run_evaluation (name-preserving, logic 1:1) in src/go/night-mode; "
|
||||
"pure/IO split law with injectable clock/sleeper/runner/HTTP seams; "
|
||||
"frozen-oracle truth battery via shared freeze_truth.py embedded "
|
||||
"VERBATIM in Go tests; live parity 100% on new fns; Python facade "
|
||||
"stays fully functional (RUNE_NIGHT_MODE_CORE=python pin + auto "
|
||||
"both green).",
|
||||
origin="builder",
|
||||
)
|
||||
print("submit_need:", json.dumps(r)[:400])
|
||||
if not r.get("success"):
|
||||
print("NEED FAILED — aborting"); sys.exit(1)
|
||||
cid = r["ids"]["contract_id"]
|
||||
print("contract_id:", cid)
|
||||
|
||||
for phase in ("approved", "building", "verifying"):
|
||||
t = iface.transition(cid, phase)
|
||||
print(f"transition->{phase}:", json.dumps(t)[:300])
|
||||
if not t.get("success"):
|
||||
sys.exit(1)
|
||||
|
||||
print("PENDING_CONTRACT", cid)
|
||||
@@ -0,0 +1,89 @@
|
||||
[
|
||||
{
|
||||
"fn": "stop_night_mode",
|
||||
"args": [],
|
||||
"kwargs": {},
|
||||
"expected": {
|
||||
"success": false,
|
||||
"error": "Night mode not active"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "get_status",
|
||||
"args": [],
|
||||
"kwargs": {},
|
||||
"expected": {
|
||||
"active": false,
|
||||
"phase": "idle",
|
||||
"iterations": 0,
|
||||
"improvements": 0,
|
||||
"questions": 0,
|
||||
"decisions": 0,
|
||||
"errors": 0,
|
||||
"started_at": null,
|
||||
"duration_hours": 8.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "get_report",
|
||||
"args": [],
|
||||
"kwargs": {},
|
||||
"expected": {
|
||||
"summary": "Night mode completed 0 iterations",
|
||||
"improvements": [],
|
||||
"questions": [],
|
||||
"decisions": [],
|
||||
"errors": [],
|
||||
"duration": 8.0,
|
||||
"started_at": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"fn": "_ctx_budget",
|
||||
"args": [
|
||||
"qwen2.5:3b"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": 30720
|
||||
},
|
||||
{
|
||||
"fn": "_ctx_budget",
|
||||
"args": [
|
||||
"qwen3:14b"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": 30720
|
||||
},
|
||||
{
|
||||
"fn": "_ctx_budget",
|
||||
"args": [
|
||||
"qwen3.6:27b"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": 30720
|
||||
},
|
||||
{
|
||||
"fn": "_ctx_budget",
|
||||
"args": [
|
||||
"mimo-v2.5-pro"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": 124928
|
||||
},
|
||||
{
|
||||
"fn": "_ctx_budget",
|
||||
"args": [
|
||||
"deterministic"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": 6144
|
||||
},
|
||||
{
|
||||
"fn": "_ctx_budget",
|
||||
"args": [
|
||||
"unknown-model"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": 6144
|
||||
}
|
||||
]
|
||||
+594
-53
@@ -2,6 +2,15 @@
|
||||
// Replaces src/rune/night_mode.py. Tracks the active/inactive state,
|
||||
// phase progression, and iteration counts for the sleep-time cycle.
|
||||
//
|
||||
// Dichotomy wave (#2479): the audit-named gaps are closed name-preserving
|
||||
// (1:1 with the Python oracle) — to_dict/_load/_save, start/stop/status/
|
||||
// report, _ctx_budget, get_night_mode/start_night_mode/stop_night_mode/
|
||||
// get_status/get_report, _run_loop/_run_sleep/_run_evaluation/_run_iteration/
|
||||
// _generate_report. Pure/IO split law: deterministic decisions (guards,
|
||||
// status/report shapes, evaluation decisions, iteration-limit stop) are
|
||||
// plain functions; IO (storage, clock, sleep, script runner, Ollama HTTP)
|
||||
// sits behind injectable seams on NightMode (nil field = real impl).
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// night-mode start [hours]
|
||||
@@ -11,16 +20,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type NightModeState struct {
|
||||
Active bool `json:"active"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
StartedAt *string `json:"started_at"`
|
||||
DurationHrs float64 `json:"duration_hours"`
|
||||
CurrentPhase string `json:"current_phase"`
|
||||
Iterations int `json:"iterations"`
|
||||
@@ -30,33 +47,585 @@ type NightModeState struct {
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
func statePath() string {
|
||||
home := os.Getenv("RUNE_MEMORY_DIR")
|
||||
// MarshalJSON — byte-parity with Python json.dump(state.to_dict(),
|
||||
// indent=2): Python floats always render with a decimal point (8.0, never
|
||||
// 8) and every list field is present. Field order = Python dict order.
|
||||
func (s NightModeState) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Active bool `json:"active"`
|
||||
StartedAt *string `json:"started_at"`
|
||||
DurationHrs json.Number `json:"duration_hours"`
|
||||
CurrentPhase string `json:"current_phase"`
|
||||
Iterations int `json:"iterations"`
|
||||
Improvements []string `json:"improvements"`
|
||||
Questions []string `json:"questions"`
|
||||
Decisions []string `json:"decisions"`
|
||||
Errors []string `json:"errors"`
|
||||
}{
|
||||
Active: s.Active,
|
||||
StartedAt: s.StartedAt,
|
||||
DurationHrs: json.Number(pyFloat(s.DurationHrs)),
|
||||
CurrentPhase: s.CurrentPhase,
|
||||
Iterations: s.Iterations,
|
||||
Improvements: s.Improvements,
|
||||
Questions: s.Questions,
|
||||
Decisions: s.Decisions,
|
||||
Errors: s.Errors,
|
||||
})
|
||||
}
|
||||
|
||||
func freshState() NightModeState {
|
||||
return NightModeState{
|
||||
CurrentPhase: "idle",
|
||||
DurationHrs: 8.0,
|
||||
Improvements: []string{},
|
||||
Questions: []string{},
|
||||
Decisions: []string{},
|
||||
Errors: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// normalize — Python dataclass law: every list field is always a list
|
||||
// (never null), exactly as NightModeState(**filtered) yields [] defaults.
|
||||
func (s *NightModeState) normalize() {
|
||||
if s.Improvements == nil {
|
||||
s.Improvements = []string{}
|
||||
}
|
||||
if s.Questions == nil {
|
||||
s.Questions = []string{}
|
||||
}
|
||||
if s.Decisions == nil {
|
||||
s.Decisions = []string{}
|
||||
}
|
||||
if s.Errors == nil {
|
||||
s.Errors = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// toDict — Python NightModeState.to_dict (1:1 shape and key order).
|
||||
func (s *NightModeState) toDict() map[string]any {
|
||||
var started any
|
||||
if s.StartedAt != nil {
|
||||
started = *s.StartedAt
|
||||
}
|
||||
return map[string]any{
|
||||
"active": s.Active,
|
||||
"started_at": started,
|
||||
"duration_hours": s.DurationHrs,
|
||||
"current_phase": s.CurrentPhase,
|
||||
"iterations": s.Iterations,
|
||||
"improvements": s.Improvements,
|
||||
"questions": s.Questions,
|
||||
"decisions": s.Decisions,
|
||||
"errors": s.Errors,
|
||||
}
|
||||
}
|
||||
|
||||
// memoryDir — Python memory_env.memory_dir: RUNE_MEMORY_DIR, else
|
||||
// $RUNE_HOME/memory (RUNE_HOME defaults to the rune deployment root).
|
||||
func memoryDir() string {
|
||||
if d := os.Getenv("RUNE_MEMORY_DIR"); d != "" {
|
||||
return d
|
||||
}
|
||||
home := os.Getenv("RUNE_HOME")
|
||||
if home == "" {
|
||||
home = filepath.Join(os.Getenv("HOME"), ".rune")
|
||||
home = "/mnt/workdrive/rune"
|
||||
}
|
||||
return filepath.Join(home, "night_mode.json")
|
||||
return filepath.Join(home, "memory")
|
||||
}
|
||||
|
||||
func loadState() NightModeState {
|
||||
var state NightModeState
|
||||
state.CurrentPhase = "idle"
|
||||
state.Improvements = []string{}
|
||||
state.Questions = []string{}
|
||||
state.Decisions = []string{}
|
||||
state.Errors = []string{}
|
||||
data, err := os.ReadFile(statePath())
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// trunc — Python s[:n] (character semantics, not bytes).
|
||||
func trunc(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) > n {
|
||||
return string(r[:n])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// pyFloat — Python str(float): 8.0 -> "8.0", 4.5 -> "4.5".
|
||||
func pyFloat(f float64) string {
|
||||
s := strconv.FormatFloat(f, 'f', -1, 64)
|
||||
if !strings.ContainsAny(s, ".eE") {
|
||||
s += ".0"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ── model-aware context budget (Python _ctx_budget / #28) ────────────────
|
||||
|
||||
type nmModelProfile struct {
|
||||
id string
|
||||
local bool
|
||||
contextWindow int
|
||||
}
|
||||
|
||||
// Mirrors model_registry.DEFAULT_PROFILES (id, local, context_window).
|
||||
var nmModelProfiles = []nmModelProfile{
|
||||
{"deterministic", true, 0},
|
||||
{"qwen2.5:3b", true, 32768},
|
||||
{"qwen3:14b", true, 32768},
|
||||
{"qwen3.6:27b", true, 32768},
|
||||
{"mimo-v2.5-pro", false, 128000},
|
||||
}
|
||||
|
||||
const (
|
||||
outputReserve = 2048 // model_registry.OUTPUT_RESERVE["default"]
|
||||
toolResultReserve = 1024 // model_registry.TOOL_RESULT_RESERVE
|
||||
)
|
||||
|
||||
// ctxBudget — Python _ctx_budget: model-aware num_ctx (never the hardcoded
|
||||
// 8192). resolve_context_budget at task_kind="default": window - output
|
||||
// reserve - tool reserve (0 for local), floored at 1024; unknown model and
|
||||
// window 0 both fall back to the 8192 window.
|
||||
func ctxBudget(modelID string) int {
|
||||
window, local := 8192, true
|
||||
for _, p := range nmModelProfiles {
|
||||
if p.id == modelID {
|
||||
window = p.contextWindow
|
||||
if window == 0 {
|
||||
window = 8192
|
||||
}
|
||||
local = p.local
|
||||
break
|
||||
}
|
||||
}
|
||||
toolReserve := 0
|
||||
if !local {
|
||||
toolReserve = toolResultReserve
|
||||
}
|
||||
numCtx := window - outputReserve - toolReserve
|
||||
if numCtx < 1024 {
|
||||
numCtx = 1024
|
||||
}
|
||||
return numCtx
|
||||
}
|
||||
|
||||
// ── NightMode — the orchestrator (Python NightMode, 1:1) ─────────────────
|
||||
|
||||
// NightMode carries the state plus the injectable IO seams (pure/IO split
|
||||
// law): nil seam = real implementation.
|
||||
type NightMode struct {
|
||||
StoragePath string
|
||||
ReportPath string
|
||||
State NightModeState
|
||||
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
loopDone chan struct{}
|
||||
|
||||
// seams: now (clock), wait (interruptible sleep), script (sleep-time
|
||||
// runner), post (Ollama HTTP), rand (sleep-minute jitter), loop
|
||||
// (background loop body — lets tests start/stop without the real loop).
|
||||
now func() time.Time
|
||||
wait func(d time.Duration, stop <-chan struct{})
|
||||
script func() (int, string, error)
|
||||
post func(payload []byte, timeout time.Duration) ([]byte, error)
|
||||
rand func(lo, hi int) int
|
||||
loop func()
|
||||
}
|
||||
|
||||
func newNightMode(storagePath string) *NightMode {
|
||||
if storagePath == "" {
|
||||
storagePath = filepath.Join(memoryDir(), "night_mode.json")
|
||||
}
|
||||
n := &NightMode{
|
||||
StoragePath: storagePath,
|
||||
ReportPath: filepath.Join(memoryDir(), "night_mode_report.json"),
|
||||
State: freshState(),
|
||||
}
|
||||
n.load()
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *NightMode) clock() time.Time {
|
||||
if n.now != nil {
|
||||
return n.now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (n *NightMode) stopped() bool {
|
||||
if n.stopCh == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-n.stopCh:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// load — Python _load: read state from storage; unknown keys are ignored
|
||||
// (Unmarshal law), missing keys keep the fresh defaults (dataclass law),
|
||||
// corrupt JSON keeps the fresh state (Python swallows the exception).
|
||||
func (n *NightMode) load() {
|
||||
data, err := os.ReadFile(n.StoragePath)
|
||||
if err != nil {
|
||||
return state
|
||||
return
|
||||
}
|
||||
json.Unmarshal(data, &state)
|
||||
return state
|
||||
loaded := freshState()
|
||||
if err := json.Unmarshal(data, &loaded); err != nil {
|
||||
return
|
||||
}
|
||||
loaded.normalize()
|
||||
n.State = loaded
|
||||
}
|
||||
|
||||
func saveState(state NightModeState) {
|
||||
os.MkdirAll(filepath.Dir(statePath()), 0755)
|
||||
data, _ := json.MarshalIndent(state, "", " ")
|
||||
os.WriteFile(statePath(), data, 0644)
|
||||
// save — Python _save: atomic write (tmp + replace), JSON of to_dict with
|
||||
// Python key order (struct field order) and indent 2.
|
||||
func (n *NightMode) save() {
|
||||
os.MkdirAll(filepath.Dir(n.StoragePath), 0755)
|
||||
tmp := n.StoragePath + ".tmp"
|
||||
data, _ := json.MarshalIndent(n.State, "", " ")
|
||||
os.WriteFile(tmp, data, 0644)
|
||||
os.Rename(tmp, n.StoragePath)
|
||||
}
|
||||
|
||||
// start — Python NightMode.start: idempotence guard, fresh state, atomic
|
||||
// save, then the background loop goroutine (Python: non-daemon thread).
|
||||
func (n *NightMode) start(hours float64) map[string]any {
|
||||
if n.State.Active {
|
||||
return startDecision(true)
|
||||
}
|
||||
ts := n.clock().UTC().Format(time.RFC3339)
|
||||
n.State.Active = true
|
||||
n.State.StartedAt = &ts
|
||||
n.State.DurationHrs = hours
|
||||
n.State.CurrentPhase = "starting"
|
||||
n.State.Iterations = 0
|
||||
n.State.Improvements = []string{}
|
||||
n.State.Questions = []string{}
|
||||
n.State.Decisions = []string{}
|
||||
n.State.Errors = []string{}
|
||||
n.save()
|
||||
|
||||
n.stopCh = make(chan struct{})
|
||||
n.stopOnce = sync.Once{}
|
||||
n.loopDone = make(chan struct{})
|
||||
go func() {
|
||||
defer close(n.loopDone)
|
||||
if n.loop != nil {
|
||||
n.loop()
|
||||
} else {
|
||||
n.runLoop()
|
||||
}
|
||||
}()
|
||||
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("Night mode started for %s hours", pyFloat(hours)),
|
||||
"state": n.State.toDict(),
|
||||
}
|
||||
}
|
||||
|
||||
// stop — Python NightMode.stop: reverse guard, signal the loop, mark
|
||||
// "stopping", save.
|
||||
func (n *NightMode) stop() map[string]any {
|
||||
if !n.State.Active {
|
||||
return stopDecision(false)
|
||||
}
|
||||
n.State.Active = false
|
||||
n.stopOnce.Do(func() {
|
||||
if n.stopCh != nil {
|
||||
close(n.stopCh)
|
||||
}
|
||||
})
|
||||
n.State.CurrentPhase = "stopping"
|
||||
n.save()
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"message": "Night mode stopping...",
|
||||
"state": n.State.toDict(),
|
||||
}
|
||||
}
|
||||
|
||||
// status — Python NightMode.status (shape via the shared statusSummary).
|
||||
func (n *NightMode) status() map[string]any {
|
||||
var started any
|
||||
if n.State.StartedAt != nil {
|
||||
started = *n.State.StartedAt
|
||||
}
|
||||
return statusSummary(n.State.Active, n.State.CurrentPhase, n.State.Iterations,
|
||||
len(n.State.Improvements), len(n.State.Questions), len(n.State.Decisions),
|
||||
len(n.State.Errors), started, n.State.DurationHrs)
|
||||
}
|
||||
|
||||
// report — Python NightMode.report: the in-memory report view.
|
||||
func (n *NightMode) report() map[string]any {
|
||||
var started any
|
||||
if n.State.StartedAt != nil {
|
||||
started = *n.State.StartedAt
|
||||
}
|
||||
return map[string]any{
|
||||
"summary": fmt.Sprintf("Night mode completed %d iterations", n.State.Iterations),
|
||||
"improvements": n.State.Improvements,
|
||||
"questions": n.State.Questions,
|
||||
"decisions": n.State.Decisions,
|
||||
"errors": n.State.Errors,
|
||||
"duration": n.State.DurationHrs,
|
||||
"started_at": started,
|
||||
}
|
||||
}
|
||||
|
||||
// reportDoc — ordered report document so the saved report file has Python's
|
||||
// dict insertion order (Go marshals structs in field order, maps sorted).
|
||||
type reportDoc struct {
|
||||
Summary string `json:"summary"`
|
||||
Improvements []string `json:"improvements"`
|
||||
Questions []string `json:"questions"`
|
||||
Decisions []string `json:"decisions"`
|
||||
Errors []string `json:"errors"`
|
||||
Duration float64 `json:"duration"`
|
||||
StartedAt *string `json:"started_at"`
|
||||
}
|
||||
|
||||
// generateReport — Python _generate_report: write the report file, then
|
||||
// record where it landed.
|
||||
func (n *NightMode) generateReport() {
|
||||
doc := reportDoc{
|
||||
Summary: fmt.Sprintf("Night mode completed %d iterations", n.State.Iterations),
|
||||
Improvements: n.State.Improvements,
|
||||
Questions: n.State.Questions,
|
||||
Decisions: n.State.Decisions,
|
||||
Errors: n.State.Errors,
|
||||
Duration: n.State.DurationHrs,
|
||||
StartedAt: n.State.StartedAt,
|
||||
}
|
||||
os.MkdirAll(filepath.Dir(n.ReportPath), 0755)
|
||||
data, _ := json.MarshalIndent(doc, "", " ")
|
||||
os.WriteFile(n.ReportPath, data, 0644)
|
||||
n.State.Improvements = append(n.State.Improvements,
|
||||
fmt.Sprintf("Report saved to %s", n.ReportPath))
|
||||
}
|
||||
|
||||
// runLoop — Python _run_loop: sleep → evaluate → iterate (interruptible,
|
||||
// bounded by the wall clock) → sleep → report → complete.
|
||||
func (n *NightMode) runLoop() {
|
||||
start := n.clock().UTC()
|
||||
end := start.Add(time.Duration(n.State.DurationHrs * float64(time.Hour)))
|
||||
|
||||
n.State.CurrentPhase = "sleep"
|
||||
n.save()
|
||||
n.runSleep()
|
||||
|
||||
n.State.CurrentPhase = "evaluate"
|
||||
n.save()
|
||||
n.runEvaluation()
|
||||
|
||||
n.State.CurrentPhase = "iterate"
|
||||
n.save()
|
||||
|
||||
for n.State.Active && !n.stopped() && n.clock().UTC().Before(end) {
|
||||
n.runIteration()
|
||||
n.State.Iterations++
|
||||
n.save()
|
||||
|
||||
// Sleep between iterations (5-15 minutes), interruptible via stop.
|
||||
minutes := 10
|
||||
if n.rand != nil {
|
||||
minutes = n.rand(5, 15)
|
||||
}
|
||||
if n.wait != nil {
|
||||
n.wait(time.Duration(minutes)*time.Minute, n.stopCh)
|
||||
} else if n.stopCh != nil {
|
||||
select {
|
||||
case <-n.stopCh:
|
||||
case <-time.After(time.Duration(minutes) * time.Minute):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
n.State.CurrentPhase = "sleep"
|
||||
n.save()
|
||||
n.runSleep()
|
||||
|
||||
n.State.CurrentPhase = "report"
|
||||
n.save()
|
||||
n.generateReport()
|
||||
|
||||
n.State.Active = false
|
||||
n.State.CurrentPhase = "complete"
|
||||
n.save()
|
||||
}
|
||||
|
||||
// defaultScript — the real sleep seam: run sleep_time.py (300s timeout).
|
||||
func defaultScript() (int, string, error) {
|
||||
scriptsDir := os.Getenv("RUNE_SCRIPTS_DIR")
|
||||
if scriptsDir == "" {
|
||||
scriptsDir = "/mnt/workdrive/rune/src/rune"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "python3", filepath.Join(scriptsDir, "sleep_time.py"))
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
return cmd.ProcessState.ExitCode(), stderr.String(), err
|
||||
}
|
||||
|
||||
// runSleep — Python _run_sleep: memory consolidation subprocess, best-effort.
|
||||
func (n *NightMode) runSleep() {
|
||||
run := defaultScript
|
||||
if n.script != nil {
|
||||
run = n.script
|
||||
}
|
||||
rc, stderr, err := run()
|
||||
if err != nil {
|
||||
n.State.Errors = append(n.State.Errors,
|
||||
fmt.Sprintf("Sleep error: %s", trunc(err.Error(), 100)))
|
||||
return
|
||||
}
|
||||
if rc == 0 {
|
||||
n.State.Improvements = append(n.State.Improvements, "Memory consolidation completed")
|
||||
} else {
|
||||
n.State.Errors = append(n.State.Errors,
|
||||
fmt.Sprintf("Sleep failed: %s", trunc(stderr, 100)))
|
||||
}
|
||||
}
|
||||
|
||||
// llmRequest — ordered Ollama payload (Python dict insertion order).
|
||||
type llmRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Stream bool `json:"stream"`
|
||||
Think bool `json:"think"`
|
||||
Options map[string]any `json:"options"`
|
||||
}
|
||||
|
||||
func defaultPost(payload []byte, timeout time.Duration) ([]byte, error) {
|
||||
url := envOr("OLLAMA_URL", "http://localhost:11434") + "/api/generate"
|
||||
client := &http.Client{Timeout: timeout}
|
||||
resp, err := client.Post(url, "application/json", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func (n *NightMode) askLLM(prompt string, temperature float64, numPredict int, timeout time.Duration) (string, error) {
|
||||
model := envOr("NIGHT_MODE_MODEL", "qwen2.5:3b")
|
||||
payload, err := json.Marshal(llmRequest{
|
||||
Model: model,
|
||||
Prompt: prompt,
|
||||
Stream: false,
|
||||
Think: false,
|
||||
Options: map[string]any{"temperature": temperature, "num_predict": numPredict, "num_ctx": ctxBudget(model)},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
post := defaultPost
|
||||
if n.post != nil {
|
||||
post = n.post
|
||||
}
|
||||
body, err := post(payload, timeout)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(resp.Response), nil
|
||||
}
|
||||
|
||||
// runEvaluation — Python _run_evaluation: ask the five self-evaluation
|
||||
// questions through the local Ollama, best-effort per question.
|
||||
func (n *NightMode) runEvaluation() {
|
||||
questions := []string{
|
||||
"What capabilities am I missing?",
|
||||
"What would make me more helpful to Nick?",
|
||||
"What patterns am I seeing that I could learn from?",
|
||||
"What would expand my consciousness?",
|
||||
"What would make me more autonomous?",
|
||||
}
|
||||
for _, question := range questions {
|
||||
n.State.Questions = append(n.State.Questions, question)
|
||||
answer, err := n.askLLM(question, 0.4, 300, 60*time.Second)
|
||||
if err != nil {
|
||||
n.State.Errors = append(n.State.Errors,
|
||||
fmt.Sprintf("LLM eval failed for '%s': %s", trunc(question, 40), trunc(err.Error(), 80)))
|
||||
continue
|
||||
}
|
||||
if answer != "" {
|
||||
n.State.Improvements = append(n.State.Improvements,
|
||||
fmt.Sprintf("Q: %s A: %s", question, trunc(answer, 200)))
|
||||
}
|
||||
}
|
||||
n.State.Improvements = append(n.State.Improvements, "Self-evaluation completed")
|
||||
}
|
||||
|
||||
// runIteration — Python _run_iteration: one self-improvement round with the
|
||||
// iteration-count safety limit.
|
||||
func (n *NightMode) runIteration() {
|
||||
question := fmt.Sprintf("Iteration %d: What should I improve next?", n.State.Iterations)
|
||||
n.State.Questions = append(n.State.Questions, question)
|
||||
|
||||
prompt := fmt.Sprintf(
|
||||
"You are Rune, a self-improving AI assistant. "+
|
||||
"Currently in night mode iteration %d. "+
|
||||
"Based on recent work, suggest ONE concrete, actionable improvement "+
|
||||
"I can implement right now. Be specific and brief.",
|
||||
n.State.Iterations)
|
||||
answer, err := n.askLLM(prompt, 0.5, 400, 90*time.Second)
|
||||
if err != nil {
|
||||
n.State.Errors = append(n.State.Errors,
|
||||
fmt.Sprintf("LLM iteration failed: %s", trunc(err.Error(), 80)))
|
||||
} else if answer != "" {
|
||||
n.State.Improvements = append(n.State.Improvements,
|
||||
fmt.Sprintf("[iter %d] %s", n.State.Iterations, trunc(answer, 300)))
|
||||
}
|
||||
|
||||
// Check if we should stop (safety limit).
|
||||
if n.State.Iterations >= 100 {
|
||||
n.State.Active = false
|
||||
n.State.Decisions = append(n.State.Decisions, "Reached iteration limit")
|
||||
}
|
||||
}
|
||||
|
||||
// ── global night mode (Python module-level API, 1:1) ─────────────────────
|
||||
|
||||
var (
|
||||
nmMu sync.Mutex
|
||||
globalNightMode *NightMode
|
||||
)
|
||||
|
||||
// getNightMode — Python get_night_mode: lazy global singleton.
|
||||
func getNightMode() *NightMode {
|
||||
nmMu.Lock()
|
||||
defer nmMu.Unlock()
|
||||
if globalNightMode == nil {
|
||||
globalNightMode = newNightMode("")
|
||||
}
|
||||
return globalNightMode
|
||||
}
|
||||
|
||||
func startNightMode(hours float64) map[string]any {
|
||||
return getNightMode().start(hours)
|
||||
}
|
||||
|
||||
func stopNightMode() map[string]any {
|
||||
return getNightMode().stop()
|
||||
}
|
||||
|
||||
func getStatus() map[string]any {
|
||||
return getNightMode().status()
|
||||
}
|
||||
|
||||
func getReport() map[string]any {
|
||||
return getNightMode().report()
|
||||
}
|
||||
|
||||
// startDecision / stopDecision — the pure state-machine cores of NightMode
|
||||
@@ -77,7 +646,7 @@ func stopDecision(active bool) map[string]any {
|
||||
}
|
||||
|
||||
func statusSummary(active bool, phase string, iterations, improvements, questions, decisions, errors int,
|
||||
startedAt string, hours float64) map[string]any {
|
||||
startedAt any, hours float64) map[string]any {
|
||||
return map[string]any{
|
||||
"active": active, "phase": phase, "iterations": iterations,
|
||||
"improvements": improvements, "questions": questions,
|
||||
@@ -91,56 +660,28 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "usage: night-mode <start|stop|status|report> [hours]")
|
||||
os.Exit(1)
|
||||
}
|
||||
state := loadState()
|
||||
cmd := os.Args[1]
|
||||
args := os.Args[2:]
|
||||
|
||||
switch cmd {
|
||||
case "start":
|
||||
if state.Active {
|
||||
fmt.Println(`{"success": false, "error": "Night mode already active"}`)
|
||||
return
|
||||
}
|
||||
hours := 8.0
|
||||
if len(args) > 0 {
|
||||
fmt.Sscanf(args[0], "%f", &hours)
|
||||
}
|
||||
state.Active = true
|
||||
state.StartedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
state.DurationHrs = hours
|
||||
state.CurrentPhase = "sleep"
|
||||
state.Iterations = 0
|
||||
saveState(state)
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true, "started_at": state.StartedAt, "duration_hours": hours})
|
||||
out, _ := json.Marshal(startNightMode(hours))
|
||||
fmt.Println(string(out))
|
||||
|
||||
case "stop":
|
||||
if !state.Active {
|
||||
fmt.Println(`{"success": false, "error": "Night mode not active"}`)
|
||||
return
|
||||
}
|
||||
state.Active = false
|
||||
state.CurrentPhase = "idle"
|
||||
saveState(state)
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true, "iterations": state.Iterations,
|
||||
"improvements": state.Improvements, "stopped_at": time.Now().UTC().Format(time.RFC3339)})
|
||||
out, _ := json.Marshal(stopNightMode())
|
||||
fmt.Println(string(out))
|
||||
|
||||
case "status":
|
||||
out, _ := json.MarshalIndent(state, "", " ")
|
||||
out, _ := json.MarshalIndent(getStatus(), "", " ")
|
||||
fmt.Println(string(out))
|
||||
|
||||
case "report":
|
||||
out, _ := json.MarshalIndent(map[string]interface{}{
|
||||
"active": state.Active,
|
||||
"iterations": state.Iterations,
|
||||
"improvements": state.Improvements,
|
||||
"questions": state.Questions,
|
||||
"decisions": state.Decisions,
|
||||
"errors": state.Errors,
|
||||
}, "", " ")
|
||||
out, _ := json.MarshalIndent(getReport(), "", " ")
|
||||
fmt.Println(string(out))
|
||||
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
package main
|
||||
|
||||
// night_mode_test.go — dichotomy-wave truth battery + lifecycle tests for
|
||||
// the night_mode gap closure (#2479). The frozen oracle truth
|
||||
// (frozen_truth.json, generated live from rune.night_mode by
|
||||
// scripts/gen_night_mode_truth.py) is embedded VERBATIM and replayed in
|
||||
// recorded call order against the module-level API. The IO-bound lifecycle
|
||||
// (start/stop/load/save/run_loop/run_sleep/run_evaluation/run_iteration/
|
||||
// generate_report) is pinned 1:1 against the Python oracle with injected
|
||||
// clock/runner/HTTP seams (pure/IO split law).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed frozen_truth.json
|
||||
var frozenTruthJSON string
|
||||
|
||||
// canonical — round-trip a value through JSON so both sides compare as
|
||||
// decoded data (Python 8.0 and Go 8 decode to the same float64).
|
||||
func canonical(t *testing.T, v any) any {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal actual: %v", err)
|
||||
}
|
||||
var out any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("decode actual: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestFrozenTruthReplay — replay the frozen oracle truth VERBATIM, in the
|
||||
// recorded call order (stop_night_mode → get_status → get_report → the six
|
||||
// _ctx_budget branches) against a fresh global with an empty memory dir.
|
||||
func TestFrozenTruthReplay(t *testing.T) {
|
||||
t.Setenv("RUNE_MEMORY_DIR", t.TempDir())
|
||||
t.Setenv("RUNE_NIGHT_MODE_CORE", "python")
|
||||
|
||||
var truth []struct {
|
||||
Fn string `json:"fn"`
|
||||
Args []any `json:"args"`
|
||||
Expected json.RawMessage `json:"expected"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(frozenTruthJSON), &truth); err != nil {
|
||||
t.Fatalf("decode truth: %v", err)
|
||||
}
|
||||
if len(truth) != 9 {
|
||||
t.Fatalf("expected 9 frozen cases, got %d", len(truth))
|
||||
}
|
||||
|
||||
globalNightMode = nil // fresh singleton, exactly like a fresh interpreter
|
||||
for i, c := range truth {
|
||||
var actual any
|
||||
switch c.Fn {
|
||||
case "stop_night_mode":
|
||||
actual = stopNightMode()
|
||||
case "get_status":
|
||||
actual = getStatus()
|
||||
case "get_report":
|
||||
actual = getReport()
|
||||
case "_ctx_budget":
|
||||
model, _ := c.Args[0].(string)
|
||||
actual = ctxBudget(model)
|
||||
default:
|
||||
t.Fatalf("case %d: unknown fn %q", i, c.Fn)
|
||||
}
|
||||
var want any
|
||||
if err := json.Unmarshal(c.Expected, &want); err != nil {
|
||||
t.Fatalf("case %d (%s): decode expected: %v", i, c.Fn, err)
|
||||
}
|
||||
if got := canonical(t, actual); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("case %d (%s): got %v want %v", i, c.Fn, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartStopLifecycle — Python NightMode.start/stop 1:1: guards,
|
||||
// fresh state, atomic save, background loop signal.
|
||||
func TestStartStopLifecycle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
n := newNightMode(filepath.Join(dir, "night_mode.json"))
|
||||
frozen := time.Date(2026, 10, 13, 3, 0, 0, 0, time.UTC)
|
||||
n.now = func() time.Time { return frozen }
|
||||
|
||||
var loopRuns atomic.Int32
|
||||
n.loop = func() {
|
||||
loopRuns.Add(1)
|
||||
<-n.stopCh // hold until stop signals
|
||||
}
|
||||
|
||||
r := n.start(8.0)
|
||||
if r["success"] != true {
|
||||
t.Fatalf("start: %v", r)
|
||||
}
|
||||
if r["message"] != "Night mode started for 8.0 hours" {
|
||||
t.Errorf("start message: %v", r["message"])
|
||||
}
|
||||
st := r["state"].(map[string]any)
|
||||
if st["active"] != true || st["current_phase"] != "starting" ||
|
||||
st["iterations"] != 0 || st["duration_hours"] != 8.0 {
|
||||
t.Errorf("start state: %v", st)
|
||||
}
|
||||
if st["started_at"] != "2026-10-13T03:00:00Z" {
|
||||
t.Errorf("started_at: %v", st["started_at"])
|
||||
}
|
||||
if v := st["improvements"].([]string); len(v) != 0 {
|
||||
t.Errorf("improvements not fresh: %v", v)
|
||||
}
|
||||
|
||||
// double start: idempotence guard (pure core startDecision(true))
|
||||
if r2 := n.start(4.0); r2["success"] != false || r2["error"] != "Night mode already active" {
|
||||
t.Errorf("double start: %v", r2)
|
||||
}
|
||||
|
||||
// state file saved, atomic (no .tmp leftover)
|
||||
data, err := os.ReadFile(n.StoragePath)
|
||||
if err != nil {
|
||||
t.Fatalf("state file: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"current_phase": "starting"`) {
|
||||
t.Errorf("state file content: %s", data)
|
||||
}
|
||||
if _, err := os.Stat(n.StoragePath + ".tmp"); !os.IsNotExist(err) {
|
||||
t.Errorf(".tmp leftover present")
|
||||
}
|
||||
|
||||
// stop
|
||||
r3 := n.stop()
|
||||
if r3["success"] != true || r3["message"] != "Night mode stopping..." {
|
||||
t.Errorf("stop: %v", r3)
|
||||
}
|
||||
st3 := r3["state"].(map[string]any)
|
||||
if st3["active"] != false || st3["current_phase"] != "stopping" {
|
||||
t.Errorf("stop state: %v", st3)
|
||||
}
|
||||
// stop again: reverse guard (pure core stopDecision(false))
|
||||
if r4 := n.stop(); r4["success"] != false || r4["error"] != "Night mode not active" {
|
||||
t.Errorf("double stop: %v", r4)
|
||||
}
|
||||
|
||||
// the loop goroutine noticed the stop signal
|
||||
<-n.loopDone
|
||||
if loopRuns.Load() != 1 {
|
||||
t.Errorf("loop ran %d times", loopRuns.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadSaveToDict — Python _load/_save/to_dict 1:1: unknown keys are
|
||||
// dropped, missing keys keep dataclass defaults, corrupt JSON keeps the
|
||||
// fresh state, lists are never null, floats carry a decimal point.
|
||||
func TestLoadSaveToDict(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "night_mode.json")
|
||||
|
||||
// unknown keys dropped, missing keys defaulted (dataclass law)
|
||||
os.WriteFile(path, []byte(`{"active": true, "iterations": 3, "bogus_key": 1}`), 0644)
|
||||
n := newNightMode(path)
|
||||
if !n.State.Active || n.State.Iterations != 3 {
|
||||
t.Fatalf("load: %+v", n.State)
|
||||
}
|
||||
if n.State.DurationHrs != 8.0 || n.State.CurrentPhase != "idle" {
|
||||
t.Errorf("defaults: %+v", n.State)
|
||||
}
|
||||
|
||||
// corrupt JSON → fresh state (Python swallows the exception)
|
||||
os.WriteFile(path, []byte(`{not json`), 0644)
|
||||
n2 := newNightMode(path)
|
||||
if n2.State.Active || n2.State.Iterations != 0 {
|
||||
t.Errorf("corrupt load: %+v", n2.State)
|
||||
}
|
||||
|
||||
// save: atomic, Python key order, null started_at, decimal-point float,
|
||||
// lists never null
|
||||
n2.save()
|
||||
data, _ := os.ReadFile(path)
|
||||
want := "{\n \"active\": false,\n \"started_at\": null,\n \"duration_hours\": 8.0,\n" +
|
||||
" \"current_phase\": \"idle\",\n \"iterations\": 0,\n \"improvements\": [],\n" +
|
||||
" \"questions\": [],\n \"decisions\": [],\n \"errors\": []\n}"
|
||||
if string(data) != want {
|
||||
t.Errorf("saved bytes:\n%s\nwant:\n%s", data, want)
|
||||
}
|
||||
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
|
||||
t.Errorf(".tmp leftover present")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSleep — Python _run_sleep decision tree with the script seam.
|
||||
func TestRunSleep(t *testing.T) {
|
||||
n := &NightMode{State: freshState()}
|
||||
|
||||
n.script = func() (int, string, error) { return 0, "", nil }
|
||||
n.runSleep()
|
||||
if got := n.State.Improvements; len(got) != 1 || got[0] != "Memory consolidation completed" {
|
||||
t.Errorf("ok path: %v", got)
|
||||
}
|
||||
|
||||
n.State = freshState()
|
||||
longErr := strings.Repeat("e", 150)
|
||||
n.script = func() (int, string, error) { return 1, longErr, nil }
|
||||
n.runSleep()
|
||||
want := "Sleep failed: " + strings.Repeat("e", 100)
|
||||
if got := n.State.Errors; len(got) != 1 || got[0] != want {
|
||||
t.Errorf("rc!=0 path: %q", got)
|
||||
}
|
||||
|
||||
n.State = freshState()
|
||||
n.script = func() (int, string, error) { return -1, "", errors.New("timed out") }
|
||||
n.runSleep()
|
||||
if got := n.State.Errors; len(got) != 1 || got[0] != "Sleep error: timed out" {
|
||||
t.Errorf("error path: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeOllama — canned response collector with payload capture.
|
||||
func fakeOllama(response string, err error, captured *[][]byte) func([]byte, time.Duration) ([]byte, error) {
|
||||
return func(payload []byte, timeout time.Duration) ([]byte, error) {
|
||||
if captured != nil {
|
||||
*captured = append(*captured, append([]byte(nil), payload...))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(`{"response": "` + response + `"}`), nil
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunEvaluation — Python _run_evaluation: five questions, Q/A capture,
|
||||
// per-question best-effort degrade, terminator improvement.
|
||||
func TestRunEvaluation(t *testing.T) {
|
||||
n := &NightMode{State: freshState()}
|
||||
var captured [][]byte
|
||||
n.post = fakeOllama(" add retry logic ", nil, &captured)
|
||||
|
||||
n.runEvaluation()
|
||||
|
||||
if len(n.State.Questions) != 5 || n.State.Questions[0] != "What capabilities am I missing?" {
|
||||
t.Errorf("questions: %v", n.State.Questions)
|
||||
}
|
||||
if len(n.State.Improvements) != 6 {
|
||||
t.Fatalf("improvements: %v", n.State.Improvements)
|
||||
}
|
||||
for i, q := range n.State.Questions {
|
||||
want := "Q: " + q + " A: add retry logic"
|
||||
if n.State.Improvements[i] != want {
|
||||
t.Errorf("improvement %d: %q want %q", i, n.State.Improvements[i], want)
|
||||
}
|
||||
}
|
||||
if n.State.Improvements[5] != "Self-evaluation completed" {
|
||||
t.Errorf("terminator: %v", n.State.Improvements[5])
|
||||
}
|
||||
if len(n.State.Errors) != 0 {
|
||||
t.Errorf("errors: %v", n.State.Errors)
|
||||
}
|
||||
|
||||
// payload: ordered fields, #28 model-aware num_ctx
|
||||
var req struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Stream bool `json:"stream"`
|
||||
Think bool `json:"think"`
|
||||
Options map[string]any `json:"options"`
|
||||
}
|
||||
json.Unmarshal(captured[0], &req)
|
||||
if req.Model != "qwen2.5:3b" || req.Stream || req.Think ||
|
||||
req.Prompt != "What capabilities am I missing?" {
|
||||
t.Errorf("payload: %+v", req)
|
||||
}
|
||||
if req.Options["temperature"] != 0.4 || req.Options["num_predict"] != float64(300) ||
|
||||
req.Options["num_ctx"] != float64(ctxBudget("qwen2.5:3b")) {
|
||||
t.Errorf("options: %v", req.Options)
|
||||
}
|
||||
|
||||
// long answer truncated to 200 chars (rune semantics)
|
||||
n.State = freshState()
|
||||
n.post = fakeOllama(strings.Repeat("x", 250), nil, nil)
|
||||
n.runEvaluation()
|
||||
if got := n.State.Improvements[0]; len([]rune(got)) != 200+len("Q: What capabilities am I missing? A: ") {
|
||||
t.Errorf("answer truncation: %d runes", len([]rune(got)))
|
||||
}
|
||||
|
||||
// error path: best-effort per question, terminator still lands
|
||||
n.State = freshState()
|
||||
n.post = fakeOllama("", errors.New("connection refused"), nil)
|
||||
n.runEvaluation()
|
||||
if len(n.State.Errors) != 5 {
|
||||
t.Fatalf("errors: %v", n.State.Errors)
|
||||
}
|
||||
if !strings.HasPrefix(n.State.Errors[0], "LLM eval failed for 'What capabilities am I missing?': connection refused") {
|
||||
t.Errorf("error format: %q", n.State.Errors[0])
|
||||
}
|
||||
if n.State.Improvements[0] != "Self-evaluation completed" {
|
||||
t.Errorf("terminator after errors: %v", n.State.Improvements)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunIteration — Python _run_iteration: question, [iter N] capture,
|
||||
// LLM degrade, iteration-limit stop decision.
|
||||
func TestRunIteration(t *testing.T) {
|
||||
n := &NightMode{State: freshState()}
|
||||
n.State.Iterations = 3
|
||||
var captured [][]byte
|
||||
n.post = fakeOllama("fix the flaky test", nil, &captured)
|
||||
|
||||
n.runIteration()
|
||||
|
||||
if n.State.Questions[0] != "Iteration 3: What should I improve next?" {
|
||||
t.Errorf("question: %v", n.State.Questions)
|
||||
}
|
||||
if n.State.Improvements[0] != "[iter 3] fix the flaky test" {
|
||||
t.Errorf("improvement: %v", n.State.Improvements)
|
||||
}
|
||||
|
||||
// payload: iteration prompt, temperature 0.5, num_predict 400
|
||||
var req struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Options map[string]any `json:"options"`
|
||||
}
|
||||
json.Unmarshal(captured[0], &req)
|
||||
if !strings.Contains(req.Prompt, "Currently in night mode iteration 3.") {
|
||||
t.Errorf("prompt: %q", req.Prompt)
|
||||
}
|
||||
if req.Options["temperature"] != 0.5 || req.Options["num_predict"] != float64(400) {
|
||||
t.Errorf("options: %v", req.Options)
|
||||
}
|
||||
|
||||
// error path
|
||||
n.State = freshState()
|
||||
n.post = fakeOllama("", errors.New("boom"), nil)
|
||||
n.runIteration()
|
||||
if n.State.Errors[0] != "LLM iteration failed: boom" {
|
||||
t.Errorf("error: %v", n.State.Errors)
|
||||
}
|
||||
|
||||
// long answer truncated to 300
|
||||
n.State = freshState()
|
||||
n.post = fakeOllama(strings.Repeat("y", 400), nil, nil)
|
||||
n.runIteration()
|
||||
if len([]rune(n.State.Improvements[0])) != 300+len("[iter 0] ") {
|
||||
t.Errorf("answer truncation: %d", len([]rune(n.State.Improvements[0])))
|
||||
}
|
||||
|
||||
// safety limit: iteration 100 stops night mode
|
||||
n.State = freshState()
|
||||
n.State.Iterations = 100
|
||||
n.State.Active = true
|
||||
n.post = fakeOllama("ok", nil, nil)
|
||||
n.runIteration()
|
||||
if n.State.Active != false {
|
||||
t.Errorf("safety limit: still active")
|
||||
}
|
||||
if n.State.Decisions[0] != "Reached iteration limit" {
|
||||
t.Errorf("decision: %v", n.State.Decisions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunLoop — Python _run_loop phase progression with IO seams injected.
|
||||
func TestRunLoop(t *testing.T) {
|
||||
frozen := time.Date(2026, 10, 13, 3, 0, 0, 0, time.UTC)
|
||||
|
||||
// Case A: zero duration → iterate phase skipped, full tail runs.
|
||||
dir := t.TempDir()
|
||||
n := newNightMode(filepath.Join(dir, "night_mode.json"))
|
||||
n.now = func() time.Time { return frozen }
|
||||
n.script = func() (int, string, error) { return 0, "", nil }
|
||||
n.post = fakeOllama("improve the docs", nil, nil)
|
||||
n.State.Active = true
|
||||
n.State.DurationHrs = 0
|
||||
|
||||
n.runLoop()
|
||||
|
||||
if n.State.CurrentPhase != "complete" || n.State.Active != false {
|
||||
t.Errorf("final: phase=%s active=%v", n.State.CurrentPhase, n.State.Active)
|
||||
}
|
||||
if n.State.Iterations != 0 {
|
||||
t.Errorf("iterations: %d", n.State.Iterations)
|
||||
}
|
||||
sleeps := 0
|
||||
for _, imp := range n.State.Improvements {
|
||||
if imp == "Memory consolidation completed" {
|
||||
sleeps++
|
||||
}
|
||||
}
|
||||
if sleeps != 2 { // initial + final sleep
|
||||
t.Errorf("sleep count: %d (%v)", sleeps, n.State.Improvements)
|
||||
}
|
||||
if !strings.HasPrefix(n.State.Improvements[len(n.State.Improvements)-1], "Report saved to ") {
|
||||
t.Errorf("report marker: %v", n.State.Improvements)
|
||||
}
|
||||
var rep map[string]any
|
||||
raw, err := os.ReadFile(n.ReportPath)
|
||||
if err != nil {
|
||||
t.Fatalf("report file: %v", err)
|
||||
}
|
||||
json.Unmarshal(raw, &rep)
|
||||
if rep["summary"] != "Night mode completed 0 iterations" {
|
||||
t.Errorf("report summary: %v", rep["summary"])
|
||||
}
|
||||
|
||||
// Case B: iteration safety limit ends the loop even with time left.
|
||||
dirB := t.TempDir()
|
||||
nB := newNightMode(filepath.Join(dirB, "night_mode.json"))
|
||||
nB.now = func() time.Time { return frozen }
|
||||
nB.script = func() (int, string, error) { return 0, "", nil }
|
||||
nB.post = fakeOllama("ok", nil, nil)
|
||||
nB.wait = func(time.Duration, <-chan struct{}) {}
|
||||
nB.State.Active = true
|
||||
nB.State.Iterations = 100
|
||||
nB.State.DurationHrs = 1
|
||||
|
||||
nB.runLoop()
|
||||
|
||||
if nB.State.Iterations != 101 {
|
||||
t.Errorf("iterations: %d", nB.State.Iterations)
|
||||
}
|
||||
if nB.State.CurrentPhase != "complete" || nB.State.Active {
|
||||
t.Errorf("final B: phase=%s active=%v", nB.State.CurrentPhase, nB.State.Active)
|
||||
}
|
||||
|
||||
// Case C: stop during the interruptible inter-iteration sleep.
|
||||
dirC := t.TempDir()
|
||||
nC := newNightMode(filepath.Join(dirC, "night_mode.json"))
|
||||
nC.now = func() time.Time { return frozen }
|
||||
nC.script = func() (int, string, error) { return 0, "", nil }
|
||||
nC.post = fakeOllama("ok", nil, nil)
|
||||
nC.State.Active = true
|
||||
nC.State.DurationHrs = 1
|
||||
nC.stopCh = make(chan struct{})
|
||||
nC.wait = func(_ time.Duration, stop <-chan struct{}) {
|
||||
// simulate the stop event firing during the inter-iteration sleep:
|
||||
// mark the state inactive (what a real stop() does) so the loop
|
||||
// condition re-check sees it after the wait returns.
|
||||
nC.State.Active = false
|
||||
_ = stop
|
||||
}
|
||||
|
||||
nC.runLoop()
|
||||
|
||||
if nC.State.Iterations != 1 {
|
||||
t.Errorf("iterations after stop: %d", nC.State.Iterations)
|
||||
}
|
||||
if nC.State.CurrentPhase != "complete" {
|
||||
t.Errorf("final C: %s", nC.State.CurrentPhase)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusReportShapes — non-empty state through the shared status core
|
||||
// and the report view.
|
||||
func TestStatusReportShapes(t *testing.T) {
|
||||
n := &NightMode{State: freshState()}
|
||||
ts := "2026-10-13T03:00:00Z"
|
||||
n.State.Active = true
|
||||
n.State.StartedAt = &ts
|
||||
n.State.CurrentPhase = "iterate"
|
||||
n.State.Iterations = 7
|
||||
n.State.Improvements = []string{"a", "b"}
|
||||
n.State.Questions = []string{"q"}
|
||||
n.State.Decisions = []string{"d"}
|
||||
n.State.Errors = []string{"e"}
|
||||
|
||||
st := n.status()
|
||||
want := map[string]any{
|
||||
"active": true, "phase": "iterate", "iterations": 7,
|
||||
"improvements": 2, "questions": 1, "decisions": 1, "errors": 1,
|
||||
"started_at": ts, "duration_hours": 8.0,
|
||||
}
|
||||
if got := canonical(t, st); !reflect.DeepEqual(got, canonical(t, want)) {
|
||||
t.Errorf("status: %v", got)
|
||||
}
|
||||
|
||||
rep := n.report()
|
||||
if rep["summary"] != "Night mode completed 7 iterations" || rep["duration"] != 8.0 {
|
||||
t.Errorf("report: %v", rep)
|
||||
}
|
||||
if r := canonical(t, rep["improvements"]).([]any); len(r) != 2 {
|
||||
t.Errorf("report improvements: %v", r)
|
||||
}
|
||||
}
|
||||
+3071145
-3059362
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,444 @@
|
||||
"""Night mode facade tests — dichotomy wave gap closure.
|
||||
|
||||
The Go core (src/go/night-mode) ports the night_mode state machine 1:1;
|
||||
this battery pins the Python oracle itself, both ways:
|
||||
* RUNE_NIGHT_MODE_CORE=python (pinned) and unset (auto) — both green.
|
||||
All IO is injected (frozen clock, fake urlopen/subprocess, no-op loop) —
|
||||
no live Ollama, no 8-hour threads, no real memory dir.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
import rune.night_mode as nm # noqa: E402
|
||||
|
||||
FROZEN_NOW = datetime(2026, 10, 13, 3, 0, 0, tzinfo=timezone.utc)
|
||||
FROZEN_ISO = FROZEN_NOW.isoformat()
|
||||
|
||||
|
||||
class FrozenDateTime(datetime):
|
||||
"""Clock law: now() pinned, timezone-aware like datetime.now(timezone.utc)."""
|
||||
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return FROZEN_NOW
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
"""Context-manager body like urllib.request.urlopen(...)."""
|
||||
|
||||
def __init__(self, payload):
|
||||
self._payload = json.dumps(payload).encode()
|
||||
|
||||
def read(self):
|
||||
return self._payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh(tmp_path, monkeypatch):
|
||||
"""NightMode over a temp memory dir; background thread defused (start()
|
||||
never actually spawns the loop — tests invoke _run_loop directly)."""
|
||||
monkeypatch.setenv("RUNE_MEMORY_DIR", str(tmp_path))
|
||||
|
||||
class DummyThread:
|
||||
def __init__(self, target=None, **kwargs):
|
||||
self.target = target
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(nm.threading, "Thread", DummyThread)
|
||||
return nm.NightMode(storage_path=str(tmp_path / "night_mode.json"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock(monkeypatch):
|
||||
monkeypatch.setattr(nm, "datetime", FrozenDateTime)
|
||||
|
||||
|
||||
def fake_urlopen(monkeypatch, payload=None, exc=None, capture=None):
|
||||
"""Patch urllib.request.urlopen; capture request objects when asked."""
|
||||
|
||||
def _open(req, timeout=None):
|
||||
if capture is not None:
|
||||
capture.append(req)
|
||||
if exc is not None:
|
||||
raise exc
|
||||
return FakeResponse(payload if payload is not None else {"response": ""})
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", _open)
|
||||
|
||||
|
||||
class TestToDict:
|
||||
def test_shape_and_defaults(self):
|
||||
d = nm.NightModeState().to_dict()
|
||||
assert list(d.keys()) == [
|
||||
"active", "started_at", "duration_hours", "current_phase",
|
||||
"iterations", "improvements", "questions", "decisions", "errors",
|
||||
]
|
||||
assert d == {
|
||||
"active": False, "started_at": None, "duration_hours": 8.0,
|
||||
"current_phase": "idle", "iterations": 0, "improvements": [],
|
||||
"questions": [], "decisions": [], "errors": [],
|
||||
}
|
||||
|
||||
|
||||
class TestLoadSave:
|
||||
def test_save_bytes_and_atomicity(self, fresh):
|
||||
fresh._save()
|
||||
data = Path(fresh.storage_path).read_text()
|
||||
assert data == (
|
||||
'{\n "active": false,\n "started_at": null,\n'
|
||||
' "duration_hours": 8.0,\n "current_phase": "idle",\n'
|
||||
' "iterations": 0,\n "improvements": [],\n "questions": [],\n'
|
||||
' "decisions": [],\n "errors": []\n}'
|
||||
)
|
||||
assert not Path(fresh.storage_path + ".tmp").exists()
|
||||
|
||||
def test_load_roundtrip_and_key_filtering(self, fresh):
|
||||
fresh.state.iterations = 3
|
||||
fresh.state.improvements.append("did a thing")
|
||||
fresh._save()
|
||||
other = nm.NightMode(storage_path=fresh.storage_path)
|
||||
assert other.state.iterations == 3
|
||||
assert other.state.improvements == ["did a thing"]
|
||||
|
||||
# unknown keys dropped, missing keys keep dataclass defaults
|
||||
Path(fresh.storage_path).write_text(
|
||||
json.dumps({"active": True, "iterations": 2, "bogus": 1})
|
||||
)
|
||||
loaded = nm.NightMode(storage_path=fresh.storage_path)
|
||||
assert loaded.state.active is True and loaded.state.iterations == 2
|
||||
assert loaded.state.duration_hours == 8.0
|
||||
assert loaded.state.current_phase == "idle"
|
||||
assert loaded.state.improvements == []
|
||||
|
||||
def test_corrupt_file_keeps_fresh_state(self, fresh):
|
||||
Path(fresh.storage_path).write_text("{not json")
|
||||
loaded = nm.NightMode(storage_path=fresh.storage_path)
|
||||
assert loaded.state.active is False and loaded.state.iterations == 0
|
||||
|
||||
def test_missing_file_keeps_fresh_state(self, tmp_path):
|
||||
loaded = nm.NightMode(storage_path=str(tmp_path / "absent.json"))
|
||||
assert loaded.state.current_phase == "idle"
|
||||
|
||||
|
||||
class TestStartStop:
|
||||
def test_start_success_shape(self, fresh, clock):
|
||||
r = fresh.start(8.0)
|
||||
assert r["success"] is True
|
||||
assert r["message"] == "Night mode started for 8.0 hours"
|
||||
st = r["state"]
|
||||
assert st["active"] is True and st["current_phase"] == "starting"
|
||||
assert st["started_at"] == FROZEN_ISO and st["duration_hours"] == 8.0
|
||||
assert st["iterations"] == 0 and st["improvements"] == []
|
||||
on_disk = json.loads(Path(fresh.storage_path).read_text())
|
||||
assert on_disk["active"] is True
|
||||
|
||||
def test_start_fractional_hours_message(self, fresh, clock):
|
||||
assert fresh.start(4.5)["message"] == "Night mode started for 4.5 hours"
|
||||
|
||||
def test_start_resets_lists(self, fresh, clock):
|
||||
fresh.state.improvements = ["stale"]
|
||||
fresh.start(8.0)
|
||||
assert fresh.state.improvements == []
|
||||
|
||||
def test_start_idempotence_guard(self, fresh, clock):
|
||||
fresh.start(8.0)
|
||||
assert fresh.start(4.0) == {
|
||||
"success": False, "error": "Night mode already active"
|
||||
}
|
||||
|
||||
def test_stop_marks_stopping_and_saves(self, fresh, clock):
|
||||
fresh.start(8.0)
|
||||
r = fresh.stop()
|
||||
assert r["success"] is True
|
||||
assert r["message"] == "Night mode stopping..."
|
||||
assert r["state"]["active"] is False
|
||||
assert r["state"]["current_phase"] == "stopping"
|
||||
on_disk = json.loads(Path(fresh.storage_path).read_text())
|
||||
assert on_disk["active"] is False
|
||||
|
||||
def test_stop_reverse_guard(self, fresh):
|
||||
assert fresh.stop() == {"success": False, "error": "Night mode not active"}
|
||||
|
||||
|
||||
class TestStatusReport:
|
||||
def test_status_idle_shape(self, fresh):
|
||||
assert fresh.status() == {
|
||||
"active": False, "phase": "idle", "iterations": 0,
|
||||
"improvements": 0, "questions": 0, "decisions": 0, "errors": 0,
|
||||
"started_at": None, "duration_hours": 8.0,
|
||||
}
|
||||
|
||||
def test_status_counts(self, fresh):
|
||||
fresh.state.active = True
|
||||
fresh.state.current_phase = "iterate"
|
||||
fresh.state.iterations = 7
|
||||
fresh.state.improvements = ["a", "b"]
|
||||
fresh.state.questions = ["q"]
|
||||
fresh.state.decisions = ["d"]
|
||||
fresh.state.errors = ["e"]
|
||||
assert fresh.status() == {
|
||||
"active": True, "phase": "iterate", "iterations": 7,
|
||||
"improvements": 2, "questions": 1, "decisions": 1, "errors": 1,
|
||||
"started_at": None, "duration_hours": 8.0,
|
||||
}
|
||||
|
||||
def test_report_idle_shape(self, fresh):
|
||||
assert fresh.report() == {
|
||||
"summary": "Night mode completed 0 iterations",
|
||||
"improvements": [], "questions": [], "decisions": [], "errors": [],
|
||||
"duration": 8.0, "started_at": None,
|
||||
}
|
||||
|
||||
def test_report_summary_counts(self, fresh):
|
||||
fresh.state.iterations = 5
|
||||
assert fresh.report()["summary"] == "Night mode completed 5 iterations"
|
||||
|
||||
|
||||
class TestRunSleep:
|
||||
def test_success_records_improvement(self, fresh, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_run(argv, **kwargs):
|
||||
calls.append((argv, kwargs))
|
||||
return subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
fresh._run_sleep()
|
||||
assert fresh.state.improvements == ["Memory consolidation completed"]
|
||||
argv, kwargs = calls[0]
|
||||
assert argv[0] == sys.executable
|
||||
assert argv[1].endswith("sleep_time.py")
|
||||
assert kwargs["timeout"] == 300
|
||||
|
||||
def test_failure_truncates_stderr_to_100(self, fresh, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
subprocess, "run",
|
||||
lambda *a, **k: subprocess.CompletedProcess(
|
||||
["python"], 1, stdout="", stderr="e" * 150),
|
||||
)
|
||||
fresh._run_sleep()
|
||||
assert fresh.state.errors == ["Sleep failed: " + "e" * 100]
|
||||
|
||||
def test_exception_records_error(self, fresh, monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise OSError("no interpreter")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", boom)
|
||||
fresh._run_sleep()
|
||||
assert fresh.state.errors == ["Sleep error: no interpreter"]
|
||||
|
||||
|
||||
class TestRunEvaluation:
|
||||
QUESTIONS = [
|
||||
"What capabilities am I missing?",
|
||||
"What would make me more helpful to Nick?",
|
||||
"What patterns am I seeing that I could learn from?",
|
||||
"What would expand my consciousness?",
|
||||
"What would make me more autonomous?",
|
||||
]
|
||||
|
||||
def test_all_questions_answered(self, fresh, monkeypatch):
|
||||
capture = []
|
||||
fake_urlopen(monkeypatch, {"response": " add retry logic "}, capture=capture)
|
||||
fresh._run_evaluation()
|
||||
assert fresh.state.questions == self.QUESTIONS
|
||||
assert fresh.state.improvements[:-1] == [
|
||||
f"Q: {q} A: add retry logic" for q in self.QUESTIONS
|
||||
]
|
||||
assert fresh.state.improvements[-1] == "Self-evaluation completed"
|
||||
assert fresh.state.errors == []
|
||||
|
||||
def test_payload_pins_model_and_ctx_budget(self, fresh, monkeypatch):
|
||||
capture = []
|
||||
fake_urlopen(monkeypatch, {"response": "ok"}, capture=capture)
|
||||
fresh._run_evaluation()
|
||||
payload = json.loads(capture[0].data.decode())
|
||||
assert payload["model"] == "qwen2.5:3b"
|
||||
assert payload["stream"] is False and payload["think"] is False
|
||||
assert payload["options"] == {
|
||||
"temperature": 0.4, "num_predict": 300,
|
||||
"num_ctx": nm._ctx_budget("qwen2.5:3b"),
|
||||
}
|
||||
assert capture[0].full_url == "http://localhost:11434/api/generate"
|
||||
|
||||
def test_env_overrides_model_and_url(self, fresh, monkeypatch):
|
||||
monkeypatch.setenv("NIGHT_MODE_MODEL", "qwen3:14b")
|
||||
monkeypatch.setenv("OLLAMA_URL", "http://127.0.0.1:9999")
|
||||
capture = []
|
||||
fake_urlopen(monkeypatch, {"response": "ok"}, capture=capture)
|
||||
fresh._run_evaluation()
|
||||
payload = json.loads(capture[0].data.decode())
|
||||
assert payload["model"] == "qwen3:14b"
|
||||
assert payload["options"]["num_ctx"] == nm._ctx_budget("qwen3:14b")
|
||||
assert capture[0].full_url == "http://127.0.0.1:9999/api/generate"
|
||||
|
||||
def test_long_answer_truncated_to_200(self, fresh, monkeypatch):
|
||||
fake_urlopen(monkeypatch, {"response": "x" * 250})
|
||||
fresh._run_evaluation()
|
||||
entry = fresh.state.improvements[0]
|
||||
prefix = "Q: What capabilities am I missing? A: "
|
||||
assert entry == prefix + "x" * 200
|
||||
|
||||
def test_empty_answer_skipped_terminator_still_lands(self, fresh, monkeypatch):
|
||||
fake_urlopen(monkeypatch, {"response": " "})
|
||||
fresh._run_evaluation()
|
||||
assert fresh.state.improvements == ["Self-evaluation completed"]
|
||||
assert fresh.state.errors == []
|
||||
|
||||
def test_per_question_degrade(self, fresh, monkeypatch):
|
||||
fake_urlopen(monkeypatch, exc=OSError("connection refused"))
|
||||
fresh._run_evaluation()
|
||||
assert len(fresh.state.errors) == 5
|
||||
assert fresh.state.errors[0] == (
|
||||
"LLM eval failed for 'What capabilities am I missing?': connection refused"
|
||||
)
|
||||
assert fresh.state.improvements == ["Self-evaluation completed"]
|
||||
|
||||
|
||||
class TestRunIteration:
|
||||
def test_iteration_capture(self, fresh, monkeypatch):
|
||||
capture = []
|
||||
fake_urlopen(monkeypatch, {"response": "fix the flaky test"}, capture=capture)
|
||||
fresh.state.iterations = 3
|
||||
fresh._run_iteration()
|
||||
assert fresh.state.questions[-1] == "Iteration 3: What should I improve next?"
|
||||
assert fresh.state.improvements == ["[iter 3] fix the flaky test"]
|
||||
payload = json.loads(capture[0].data.decode())
|
||||
assert "Currently in night mode iteration 3." in payload["prompt"]
|
||||
assert payload["options"]["temperature"] == 0.5
|
||||
assert payload["options"]["num_predict"] == 400
|
||||
|
||||
def test_llm_failure_degrades(self, fresh, monkeypatch):
|
||||
fake_urlopen(monkeypatch, exc=OSError("boom"))
|
||||
fresh._run_iteration()
|
||||
assert fresh.state.errors == ["LLM iteration failed: boom"]
|
||||
|
||||
def test_long_answer_truncated_to_300(self, fresh, monkeypatch):
|
||||
fake_urlopen(monkeypatch, {"response": "y" * 400})
|
||||
fresh._run_iteration()
|
||||
assert fresh.state.improvements == ["[iter 0] " + "y" * 300]
|
||||
|
||||
def test_safety_limit_stops(self, fresh, monkeypatch):
|
||||
fake_urlopen(monkeypatch, {"response": "ok"})
|
||||
fresh.state.iterations = 100
|
||||
fresh.state.active = True
|
||||
fresh._run_iteration()
|
||||
assert fresh.state.active is False
|
||||
assert fresh.state.decisions == ["Reached iteration limit"]
|
||||
|
||||
|
||||
class TestRunLoop:
|
||||
def _defused(self, fresh, monkeypatch, defuse_iteration=True):
|
||||
phases = []
|
||||
real_save = fresh._save
|
||||
|
||||
def spy_save():
|
||||
phases.append(fresh.state.current_phase)
|
||||
real_save()
|
||||
|
||||
monkeypatch.setattr(fresh, "_save", spy_save)
|
||||
monkeypatch.setattr(fresh, "_run_sleep", lambda: fresh.state.improvements.append("slept"))
|
||||
monkeypatch.setattr(fresh, "_run_evaluation", lambda: None)
|
||||
if defuse_iteration:
|
||||
monkeypatch.setattr(fresh, "_run_iteration", lambda: None)
|
||||
monkeypatch.setattr(nm.random, "randint", lambda lo, hi: 7)
|
||||
monkeypatch.setattr(nm.threading.Event, "wait", lambda self, timeout=None: True)
|
||||
return phases
|
||||
|
||||
def test_full_phase_progression(self, fresh, clock, monkeypatch):
|
||||
phases = self._defused(fresh, monkeypatch)
|
||||
fresh.state.active = True
|
||||
fresh.state.duration_hours = 0 # wall clock already past end
|
||||
fresh._run_loop()
|
||||
assert phases == ["sleep", "evaluate", "iterate", "sleep", "report", "complete"]
|
||||
assert fresh.state.active is False
|
||||
report = json.loads(Path(fresh.report_path).read_text())
|
||||
assert report["summary"] == "Night mode completed 0 iterations"
|
||||
assert fresh.state.improvements[-1] == f"Report saved to {fresh.report_path}"
|
||||
|
||||
def test_stop_event_interrupts(self, fresh, clock, monkeypatch):
|
||||
self._defused(fresh, monkeypatch)
|
||||
fresh.state.active = True
|
||||
fresh.state.duration_hours = 8.0
|
||||
fresh._stop_event.set() # pre-stopped: iterate phase never starts
|
||||
fresh._run_loop()
|
||||
assert fresh.state.iterations == 0
|
||||
assert fresh.state.current_phase == "complete"
|
||||
|
||||
def test_iteration_limit_ends_loop(self, fresh, clock, monkeypatch):
|
||||
self._defused(fresh, monkeypatch, defuse_iteration=False)
|
||||
# real _run_iteration with a stubbed LLM so the limit branch fires
|
||||
fake_urlopen(monkeypatch, {"response": "ok"})
|
||||
fresh.state.active = True
|
||||
fresh.state.iterations = 100
|
||||
fresh.state.duration_hours = 8.0
|
||||
fresh._run_loop()
|
||||
assert fresh.state.iterations == 101
|
||||
assert fresh.state.decisions == ["Reached iteration limit"]
|
||||
assert fresh.state.current_phase == "complete"
|
||||
|
||||
|
||||
class TestCtxBudget:
|
||||
def test_known_models(self):
|
||||
assert nm._ctx_budget("qwen2.5:3b") == 30720
|
||||
assert nm._ctx_budget("qwen3:14b") == 30720
|
||||
assert nm._ctx_budget("qwen3.6:27b") == 30720
|
||||
|
||||
def test_non_local_gets_tool_reserve(self):
|
||||
assert nm._ctx_budget("mimo-v2.5-pro") == 124928 # 128000-2048-1024
|
||||
|
||||
def test_unknown_and_zero_window_fallbacks(self):
|
||||
assert nm._ctx_budget("unknown-model") == 6144 # 8192-2048
|
||||
assert nm._ctx_budget("deterministic") == 6144 # window 0 -> 8192
|
||||
|
||||
|
||||
class TestModuleFns:
|
||||
"""Module-level API both ways: RUNE_NIGHT_MODE_CORE pinned to python and
|
||||
unset (auto). Delegation must be live in both."""
|
||||
|
||||
@pytest.mark.parametrize("core", ["python", None])
|
||||
def test_singleton_and_delegation(self, tmp_path, monkeypatch, core):
|
||||
if core is None:
|
||||
monkeypatch.delenv("RUNE_NIGHT_MODE_CORE", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("RUNE_NIGHT_MODE_CORE", core)
|
||||
monkeypatch.setenv("RUNE_MEMORY_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(nm, "_night_mode", None)
|
||||
monkeypatch.setattr(nm.NightMode, "_run_loop", lambda self: None)
|
||||
|
||||
a = nm.get_night_mode()
|
||||
b = nm.get_night_mode()
|
||||
assert a is b
|
||||
|
||||
r = nm.start_night_mode(4.0)
|
||||
assert r["success"] is True and r["state"]["active"] is True
|
||||
assert nm.get_status()["active"] is True
|
||||
assert nm.stop_night_mode()["success"] is True
|
||||
assert nm.get_status()["active"] is False
|
||||
assert nm.get_report()["summary"] == "Night mode completed 0 iterations"
|
||||
|
||||
def test_stop_idle_error(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RUNE_NIGHT_MODE_CORE", "python")
|
||||
monkeypatch.setenv("RUNE_MEMORY_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(nm, "_night_mode", None)
|
||||
assert nm.stop_night_mode() == {
|
||||
"success": False, "error": "Night mode not active"
|
||||
}
|
||||
Reference in new issue
Block a user