cc-solver / solver.py
VeryOnly123's picture
update
5f229e4
Raw History Blame Contribute Delete
20.6 kB
#!/usr/bin/env python3
"""Background task manager for the physics solver."""
import os
import sys
import json
import time
import shutil
import subprocess
import threading
import urllib.request
from datetime import datetime, timezone
# Solver source location
SOLVER_SRC = "/app/solver"
WORKSPACE_BASE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "workspaces")
# Files that come from the repo — excluded from downloads
SOLVER_SOURCE_FILES = {"run.py", "spawn.py", "outline.md"}
DEFAULT_SOLVER_URL = os.environ.get(
"SOLVER_REPO_URL",
"https://github.com/Usami-Mira/CC_Integrater/raw/refs/heads/main/",
)
tasks = {}
tasks_lock = threading.Lock()
def _log(task_id, msg):
"""Append a line to the task's log file AND print to stdout."""
task = tasks.get(task_id)
log_path = None
if task:
log_path = os.path.join(task["workspace"], "solver.log")
if log_path:
try:
with open(log_path, "a", encoding="utf-8") as f:
f.write(f"[{_now()}] {msg}\n")
except Exception:
pass
print(f"[{task_id}] {msg}", flush=True)
def _now():
return datetime.now(timezone.utc).isoformat()
def fetch_solver_files(repo_url):
"""Download solver files from GitHub raw URL into /app/solver/."""
os.makedirs(SOLVER_SRC, exist_ok=True)
for name in ("run.py", "spawn.py", "outline.md", "config.json"):
p = os.path.join(SOLVER_SRC, name)
if os.path.exists(p):
os.remove(p)
print(f"[INIT] Fetching solver files from: {repo_url}", flush=True)
files = ["run.py", "spawn.py", "outline.md", "config.json"]
downloaded = []
errors = []
for name in files:
url = f"{repo_url.rstrip('/')}/{name}"
try:
with urllib.request.urlopen(url, timeout=30) as resp:
data = resp.read()
with open(os.path.join(SOLVER_SRC, name), "wb") as f:
f.write(data)
downloaded.append(name)
print(f"[INIT] Downloaded {name}", flush=True)
except Exception as e:
errors.append(f"{name}: {e}")
print(f"[INIT] Failed to download {name}: {e}", flush=True)
if not downloaded:
print(f"[INIT] CRITICAL: No solver files downloaded. Errors: {errors}", flush=True)
return False
if errors:
print(f"[INIT] Partial download. Missing: {errors}", flush=True)
return True
def active_count():
"""Caller must hold tasks_lock."""
return sum(1 for t in tasks.values() if t["status"] in ("pending", "running"))
def submit_task(problem_text, name=None, config_override=None, secret_key=None):
"""Create a new task, return task_id."""
print("[SUBMIT] submit_task() called", flush=True)
try:
task_id = f"task-{int(time.time()*1000)}"
workspace = os.path.join(WORKSPACE_BASE, task_id)
print(f"[SUBMIT] Creating workspace: {workspace}", flush=True)
os.makedirs(workspace, exist_ok=True)
# Write problem
with open(os.path.join(workspace, "problem.md"), "w", encoding="utf-8") as f:
f.write(problem_text)
print(f"[SUBMIT] Wrote problem.md", flush=True)
# Copy solver source into workspace
copied = []
for fname in ("run.py", "spawn.py", "outline.md", "config.json"):
src = os.path.join(SOLVER_SRC, fname)
exists = os.path.exists(src)
print(f"[SUBMIT] {fname}: exists={exists} at {src}", flush=True)
if exists:
shutil.copy2(src, os.path.join(workspace, fname))
copied.append(fname)
print(f"[SUBMIT] Copied: {copied}", flush=True)
# Apply config override if provided
if config_override:
config_path = os.path.join(workspace, "config.json")
if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f:
current = json.load(f)
current.update(config_override)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(current, f, indent=2, ensure_ascii=False)
print(f"[SUBMIT] Applied config override: {list(config_override.keys())}", flush=True)
else:
with open(config_path, "w", encoding="utf-8") as f:
json.dump(config_override, f, indent=2, ensure_ascii=False)
print(f"[SUBMIT] Created config from override", flush=True)
# Save secret key if provided (private mode)
is_private = bool(secret_key)
if is_private:
with open(os.path.join(workspace, ".secret"), "w", encoding="utf-8") as f:
f.write(secret_key)
task = {
"task_id": task_id,
"name": name or f"Problem {len(tasks)+1}",
"status": "pending",
"phase": "waiting",
"workspace": workspace,
"problem_preview": problem_text[:120],
"problem_length": len(problem_text),
"created_at": _now(),
"started_at": None,
"finished_at": None,
"error": None,
"is_private": is_private,
}
with tasks_lock:
tasks[task_id] = task
print(f"[SUBMIT] Task registered in memory", flush=True)
# Create log file
log_path = os.path.join(workspace, "solver.log")
with open(log_path, "w", encoding="utf-8") as f:
f.write(f"[{_now()}] Task {task_id} created\n")
print(f"[SUBMIT] Created task {task_id} -> {workspace}", flush=True)
# Spawn background thread — run.py manages its own timeout & concurrency
t = threading.Thread(target=_run_solver, args=(task_id, workspace), daemon=True)
t.start()
print(f"[SUBMIT] Thread started", flush=True)
return task_id
except Exception as e:
import traceback
tb = traceback.format_exc()
print(f"[SUBMIT] ERROR: {e}", flush=True)
print(f"[SUBMIT] Traceback:\n{tb}", flush=True)
raise
def _run_solver(task_id, workspace):
print(f"[{task_id}] _run_solver thread started", flush=True)
with tasks_lock:
tasks[task_id].update({"status": "running", "started_at": _now()})
_log(task_id, f"Starting solver in {workspace}")
_log(task_id, f"ANTHROPIC_API_KEY: {'set' if os.environ.get('ANTHROPIC_API_KEY') else 'NOT SET'}")
_log(task_id, f"ANTHROPIC_BASE_URL: {os.environ.get('ANTHROPIC_BASE_URL', '(default)')}")
env = os.environ.copy()
start = time.time()
proc = None
try:
cmd = [sys.executable, os.path.join(workspace, "run.py"), workspace]
print(f"[{task_id}] Running: {' '.join(cmd)}", flush=True)
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=workspace,
env=env,
)
_log(task_id, f"spawned run.py with PID {proc.pid}")
# Read stdout line by line, append to log
def _read_output():
if proc.stdout:
for line in iter(proc.stdout.readline, b""):
decoded = line.decode("utf-8", errors="replace").strip()
if decoded:
_log(task_id, f"[stdout] {decoded}")
reader = threading.Thread(target=_read_output, daemon=True)
reader.start()
# Monitor workspace for progress (no timeout — run.py handles its own)
last_phase = "starting"
last_state = _read_state(workspace)
with tasks_lock:
tasks[task_id]["file_history"] = []
while proc.poll() is None:
phase = _infer_phase(workspace)
if phase != last_phase:
_log(task_id, f"Phase: {phase}")
last_phase = phase
# Track .state changes for history
state = _read_state(workspace)
if state and state != last_state:
_log(task_id, f"State: {state}")
history = STATE_TO_HISTORY.get(state, [])
with tasks_lock:
tasks[task_id]["file_history"] = [dict(h) for h in history]
last_state = state
with tasks_lock:
tasks[task_id]["phase"] = phase
tasks[task_id]["elapsed_s"] = round(time.time() - start)
time.sleep(10)
reader.join(timeout=3)
_log(task_id, f"run.py exited with code {proc.returncode}")
if proc.returncode != 0:
_log(task_id, "Solver returned non-zero exit code")
with tasks_lock:
tasks[task_id].update({
"status": "error",
"error": f"Solver exited with code {proc.returncode}",
"finished_at": _now(),
})
return
summary = os.path.join(workspace, "final_summary.md")
if os.path.exists(summary):
_log(task_id, "Solver completed successfully")
with tasks_lock:
tasks[task_id].update({
"status": "complete",
"phase": "complete",
"finished_at": _now(),
})
else:
_log(task_id, "Solver finished but no summary file found")
with tasks_lock:
tasks[task_id].update({
"status": "error",
"error": "Solver finished but no summary file was produced.",
"finished_at": _now(),
})
except FileNotFoundError:
_log(task_id, f"FileNotFoundError: run.py not found")
with tasks_lock:
tasks[task_id].update({
"status": "error",
"error": "run.py not found in workspace.",
"finished_at": _now(),
})
except Exception as e:
import traceback
tb = traceback.format_exc()
_log(task_id, f"Exception: {e}")
_log(task_id, tb)
print(f"[{task_id}] Traceback:\n{tb}", flush=True)
with tasks_lock:
tasks[task_id].update({
"status": "error",
"error": str(e),
"finished_at": _now(),
})
# Known solver workflow steps in execution order. Unknown files append at end alphabetically.
WORKFLOW_STEPS = ["plan", "solution", "review", "final_summary"]
# Mapping from .state value to completed steps
STATE_TO_HISTORY = {
"planner": [], # not started yet
"builder": [{"step": "plan", "status": "history"}],
"evaluator": [{"step": "plan", "status": "history"}, {"step": "solution", "status": "history"}],
"done": [{"step": "plan", "status": "history"}, {"step": "solution", "status": "history"},
{"step": "review", "status": "history"}, {"step": "final_summary", "status": "history"}],
}
STATE_TO_PHASE = {
"planner": "starting",
"builder": "building",
"evaluator": "evaluating",
"done": "complete",
}
# Files to exclude from progress tracking
PROGRESS_EXCLUDES = {"problem.md", "solver.log", "outline.md"}
def _read_state(workspace):
"""Read .state file. Returns phase string (planner/builder/evaluator/done) or None."""
path = os.path.join(workspace, ".state")
if not os.path.isfile(path):
return None
try:
with open(path, encoding="utf-8") as f:
return f.read().strip()
except Exception:
return None
def _read_secret(workspace):
"""Read .secret file. Returns secret key string or None."""
path = os.path.join(workspace, ".secret")
if not os.path.isfile(path):
return None
try:
with open(path, encoding="utf-8") as f:
return f.read().strip()
except Exception:
return None
def _check_access(workspace, provided_key=None):
"""Check if provided_key grants access to a private task. Returns True if public or key matches."""
secret = _read_secret(workspace)
if not secret:
return True # not a private task
if provided_key and provided_key == secret:
return True
return False
def _build_history_from_state(workspace, state=None):
"""Build progress history from .state file, marking last completed step as 'current'."""
if state is None:
state = _read_state(workspace)
if state is None:
return None # no .state → caller should fall back to file scanning
history = STATE_TO_HISTORY.get(state)
if history is None:
return []
result = list(history)
if result:
result[-1] = dict(result[-1], status="current")
return result
def _check_files(workspace, file_history=None):
"""Return list of steps with history. file_history is an ordered list from monitoring."""
if file_history and len(file_history) > 0:
# Use monitored history
result = list(file_history)
if result:
result[-1] = dict(result[-1], status="current")
return result
# Priority 1: .state file
from_state = _build_history_from_state(workspace)
if from_state is not None:
return from_state
# Fallback: scan by mtime (for tasks created before this change or without .state)
if not os.path.isdir(workspace):
return []
mtimes = {}
for fname in os.listdir(workspace):
if fname.endswith(".md") and fname not in PROGRESS_EXCLUDES:
step = fname[:-3]
if step in WORKFLOW_STEPS:
mtimes[step] = os.path.getmtime(os.path.join(workspace, fname))
if not mtimes:
return []
latest = max(mtimes, key=mtimes.get)
result = []
for step in WORKFLOW_STEPS:
if step in mtimes:
status = "current" if step == latest else "history"
result.append({"step": step, "status": status})
other = sorted(s for s in mtimes if s not in set(WORKFLOW_STEPS))
for s in other:
result.append({"step": s, "status": "history"})
return result
def _infer_phase(workspace):
# Priority: .state file first
state = _read_state(workspace)
if state and state in STATE_TO_PHASE:
return STATE_TO_PHASE[state]
# Fallback: scan .md files
if os.path.exists(os.path.join(workspace, "final_summary.md")):
return "complete"
if os.path.exists(os.path.join(workspace, "review.md")):
return "evaluating"
if os.path.exists(os.path.join(workspace, "solution.md")):
return "building"
if os.path.exists(os.path.join(workspace, "plan.md")):
return "planning"
return "starting"
def get_task(task_id, secret_key=None):
with tasks_lock:
task = tasks.get(task_id)
if task:
# Check access for private tasks
if not _check_access(task["workspace"], secret_key):
return None
result = dict(task)
result.pop("problem_preview", None)
history = result.get("file_history")
result["files_done"] = _check_files(task["workspace"], history)
return result
return None
def get_task_result(task_id, secret_key=None):
task = get_task(task_id, secret_key)
if not task:
return None
summary = os.path.join(task.get("workspace", ""), "final_summary.md")
if os.path.exists(summary):
with open(summary, encoding="utf-8") as f:
return f.read()
return None
def get_task_logs(task_id, lines=100, secret_key=None):
with tasks_lock:
task = tasks.get(task_id)
if not task:
return [f"Task {task_id} not found in memory."]
if not _check_access(task["workspace"], secret_key):
return ["Access denied: invalid or missing secret key."]
log_path = os.path.join(task["workspace"], "solver.log")
if not os.path.exists(log_path):
return ["Waiting for solver to start..."]
try:
with open(log_path, encoding="utf-8") as f:
all_lines = f.readlines()
return [l.rstrip() for l in all_lines[-lines:]]
except Exception as e:
return [f"Error reading log: {e}"]
def get_workspace_file(task_id, filename, secret_key=None):
"""Read a file from a task's workspace. Returns content or None if not found."""
with tasks_lock:
task = tasks.get(task_id)
if not task:
return None
if not _check_access(task["workspace"], secret_key):
return None
fpath = os.path.join(task["workspace"], filename)
if not os.path.isfile(fpath):
return None
with open(fpath, encoding="utf-8") as f:
return f.read()
def list_workspace_files(task_id, secret_key=None):
"""List all non-hidden .md files in a task's workspace."""
with tasks_lock:
task = tasks.get(task_id)
if not task:
return []
if not _check_access(task["workspace"], secret_key):
return []
ws = task["workspace"]
if not os.path.isdir(ws):
return []
result = []
for fname in sorted(os.listdir(ws)):
if fname.endswith(".md") and not fname.startswith("."):
result.append(fname)
return result
def get_running_tasks():
"""Return list of currently running tasks (for frontend recovery after page refresh)."""
with tasks_lock:
return [
{
"task_id": t["task_id"],
"status": t["status"],
"phase": t["phase"],
"problem": t["problem"],
"created_at": t["created_at"],
"files_done": _check_files(t["workspace"], t.get("file_history")),
}
for t in tasks.values()
if t["status"] in ("pending", "running")
]
def list_tasks(secret_key=None):
with tasks_lock:
return [
{
"task_id": t["task_id"],
"name": t["name"],
"status": t["status"],
"phase": t["phase"],
"problem_preview": t.get("problem_preview", "") if not t.get("is_private") else "🔒",
"problem_length": t.get("problem_length", 0),
"created_at": t["created_at"],
"started_at": t.get("started_at"),
"finished_at": t.get("finished_at"),
"files_done": _check_files(t["workspace"], t.get("file_history")),
"error": t.get("error"),
"is_private": t.get("is_private", False),
}
for t in sorted(tasks.values(), key=lambda x: x["created_at"], reverse=True)
]
def delete_task(task_id, secret_key=None):
"""Remove a task from memory and delete its workspace. Returns True if found."""
with tasks_lock:
task = tasks.get(task_id)
if not task:
return False
if task.get("is_private") and not _check_access(task["workspace"], secret_key):
return False
task = tasks.pop(task_id, None)
workspace = task.get("workspace", "")
if os.path.isdir(workspace):
shutil.rmtree(workspace)
_log(task_id, f"Task {task_id} deleted")
return True
def cleanup_completed_tasks():
"""Delete all completed and errored tasks. Returns count of deleted tasks."""
with tasks_lock:
to_delete = [
tid for tid, t in tasks.items()
if t["status"] in ("complete", "error")
]
count = 0
for tid in to_delete:
if delete_task(tid):
count += 1
return count
def update_task_config(task_id, config_dict):
"""Update config.json in a task's workspace. Only allowed for pending tasks."""
with tasks_lock:
task = tasks.get(task_id)
if not task:
return None, "Task not found"
if task["status"] != "pending":
return None, f"Cannot update config for task in '{task['status']}' status (only 'pending')"
config_path = os.path.join(task["workspace"], "config.json")
try:
with open(config_path, "r", encoding="utf-8") as f:
current = json.load(f)
current.update(config_dict)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(current, f, indent=2, ensure_ascii=False)
return current, None
except FileNotFoundError:
return None, "config.json not found in workspace"
except json.JSONDecodeError:
return None, "Invalid config.json in workspace"