Complete tasks 3-7: harden verdict parsing, outputs retention, base branch, linux schedule parity, claim loop task
CI / build (push) Has been cancelled
CI / build (push) Has been cancelled
This commit is contained in:
+119
-44
@@ -75,12 +75,14 @@ def scan_all_tasks(project_dir: Path) -> list[dict]:
|
||||
tasks = []
|
||||
for name, path in _all_tasks(project_dir):
|
||||
phase = _read_state(path)
|
||||
tasks.append({
|
||||
"name": name,
|
||||
"path": str(path),
|
||||
"phase": phase,
|
||||
"base_phase": _base_phase(phase) if phase else None,
|
||||
})
|
||||
tasks.append(
|
||||
{
|
||||
"name": name,
|
||||
"path": str(path),
|
||||
"phase": phase,
|
||||
"base_phase": _base_phase(phase) if phase else None,
|
||||
}
|
||||
)
|
||||
return tasks
|
||||
|
||||
|
||||
@@ -104,14 +106,20 @@ def needs_user_input(task: dict) -> bool:
|
||||
if verdict_file.exists():
|
||||
content = verdict_file.read_text()
|
||||
first_line = content.split("\n")[0] if content else ""
|
||||
if any(kw in first_line.upper() for kw in ("FAIL", "NEEDS_REVIEW", "TIE-BREAK")):
|
||||
if any(
|
||||
kw in first_line.upper() for kw in ("FAIL", "NEEDS_REVIEW", "TIE-BREAK")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def sort_by_advancement(tasks: list[dict]) -> list[dict]:
|
||||
"""Sort tasks by how close they are to completion (most advanced first)."""
|
||||
return sorted(tasks, key=lambda t: PHASE_PRIORITY.get(t.get("base_phase", ""), 0), reverse=True)
|
||||
return sorted(
|
||||
tasks,
|
||||
key=lambda t: PHASE_PRIORITY.get(t.get("base_phase", ""), 0),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
def detect_stuck_tasks(project_dir: Path, threshold_minutes: int = 60) -> list[dict]:
|
||||
@@ -128,12 +136,14 @@ def detect_stuck_tasks(project_dir: Path, threshold_minutes: int = 60) -> list[d
|
||||
mtime = state_file.stat().st_mtime
|
||||
age_minutes = (now - mtime) / 60
|
||||
if age_minutes > threshold_minutes:
|
||||
stuck.append({
|
||||
"name": name,
|
||||
"phase": phase,
|
||||
"age_minutes": round(age_minutes),
|
||||
"path": str(path),
|
||||
})
|
||||
stuck.append(
|
||||
{
|
||||
"name": name,
|
||||
"phase": phase,
|
||||
"age_minutes": round(age_minutes),
|
||||
"path": str(path),
|
||||
}
|
||||
)
|
||||
return sorted(stuck, key=lambda t: t["age_minutes"], reverse=True)
|
||||
|
||||
|
||||
@@ -144,7 +154,10 @@ def cmd_summary(args):
|
||||
|
||||
if not all_tasks:
|
||||
print("No tasks found. Create one with:")
|
||||
print(" python ~/.automaton/scripts/status.py --create-task <name> --project", project_dir)
|
||||
print(
|
||||
" python ~/.automaton/scripts/status.py --create-task <name> --project",
|
||||
project_dir,
|
||||
)
|
||||
return 0
|
||||
|
||||
terminal = [t for t in all_tasks if is_terminal(t)]
|
||||
@@ -154,7 +167,9 @@ def cmd_summary(args):
|
||||
stuck = detect_stuck_tasks(project_dir)
|
||||
|
||||
print(f"Project: {project_dir}")
|
||||
print(f"Tasks: {len(all_tasks)} total | {len(terminal)} done | {len(non_terminal)} in progress")
|
||||
print(
|
||||
f"Tasks: {len(all_tasks)} total | {len(terminal)} done | {len(non_terminal)} in progress"
|
||||
)
|
||||
print(f" Blocked (awaiting user): {len(blocked)}")
|
||||
print(f" Unblocked (ready to drive): {len(unblocked)}")
|
||||
print(f" Stuck (>60 min): {len(stuck)}")
|
||||
@@ -169,20 +184,27 @@ def cmd_summary(args):
|
||||
if unblocked:
|
||||
print("=== READY TO DRIVE ===")
|
||||
for t in sort_by_advancement(unblocked):
|
||||
print(f" {t['name']}: {t['phase']} (priority: {PHASE_PRIORITY.get(t.get('base_phase', ''), 0)})")
|
||||
print(
|
||||
f" {t['name']}: {t['phase']} (priority: {PHASE_PRIORITY.get(t.get('base_phase', ''), 0)})"
|
||||
)
|
||||
print()
|
||||
|
||||
if blocked:
|
||||
print("=== AWAITING USER ===")
|
||||
for t in blocked:
|
||||
approval = t["phase"].endswith(":awaiting_approval")
|
||||
print(f" {t['name']}: {t['phase']}{' (needs --approve)' if approval else ' (needs VERDICT review)'}")
|
||||
print(
|
||||
f" {t['name']}: {t['phase']}{' (needs --approve)' if approval else ' (needs VERDICT review)'}"
|
||||
)
|
||||
print()
|
||||
|
||||
if not unblocked and not non_terminal:
|
||||
print("ALL TASKS TERMINAL — nothing to drive.")
|
||||
print("Create a new task to continue:")
|
||||
print(" python ~/.automaton/scripts/status.py --create-task <name> --project", project_dir)
|
||||
print(
|
||||
" python ~/.automaton/scripts/status.py --create-task <name> --project",
|
||||
project_dir,
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
@@ -194,7 +216,10 @@ def cmd_drive(args):
|
||||
|
||||
if not all_tasks:
|
||||
print("NO_TASKS: No tasks found. Create one with:")
|
||||
print(" python ~/.automaton/scripts/status.py --create-task <name> --project", project_dir)
|
||||
print(
|
||||
" python ~/.automaton/scripts/status.py --create-task <name> --project",
|
||||
project_dir,
|
||||
)
|
||||
return 1
|
||||
|
||||
non_terminal = [t for t in all_tasks if not is_terminal(t)]
|
||||
@@ -202,7 +227,10 @@ def cmd_drive(args):
|
||||
if not non_terminal:
|
||||
print("ORCHESTRATION_COMPLETE: all tasks done.")
|
||||
print("Nothing to drive. Create a new task:")
|
||||
print(" python ~/.automaton/scripts/status.py --create-task <name> --project", project_dir)
|
||||
print(
|
||||
" python ~/.automaton/scripts/status.py --create-task <name> --project",
|
||||
project_dir,
|
||||
)
|
||||
return 0
|
||||
|
||||
unblocked = [t for t in non_terminal if not needs_user_input(t)]
|
||||
@@ -215,7 +243,9 @@ def cmd_drive(args):
|
||||
print("To proceed:")
|
||||
for t in non_terminal:
|
||||
if t["phase"].endswith(":awaiting_approval"):
|
||||
print(f" python ~/.automaton/scripts/status.py --approve --task {t['name']} --project {project_dir}")
|
||||
print(
|
||||
f" python ~/.automaton/scripts/status.py --approve --task {t['name']} --project {project_dir}"
|
||||
)
|
||||
else:
|
||||
print(f" Review {t['name']}/VERDICT.md and take action")
|
||||
return 0
|
||||
@@ -233,53 +263,85 @@ def cmd_drive(args):
|
||||
|
||||
if base == "new":
|
||||
print("→ Transition to research:")
|
||||
print(f" python ~/.automaton/scripts/status.py --transition research --task {task['name']} --project {project_dir}")
|
||||
print(
|
||||
f" python ~/.automaton/scripts/status.py --transition research --task {task['name']} --project {project_dir}"
|
||||
)
|
||||
elif base == "research":
|
||||
if phase == "research":
|
||||
print("→ Generate SPEC.md, then transition to awaiting_approval:")
|
||||
print(f" python ~/.automaton/scripts/status.py --transition research:awaiting_approval --task {task['name']} --project {project_dir}")
|
||||
print(
|
||||
f" python ~/.automaton/scripts/status.py --transition research:awaiting_approval --task {task['name']} --project {project_dir}"
|
||||
)
|
||||
elif phase == "research:approved":
|
||||
print("→ Transition to next phase (decomposition/design/implement):")
|
||||
print(f" python ~/.automaton/scripts/status.py --transition decomposition --task {task['name']} --project {project_dir}")
|
||||
print(
|
||||
f" python ~/.automaton/scripts/status.py --transition decomposition --task {task['name']} --project {project_dir}"
|
||||
)
|
||||
elif base == "decomposition":
|
||||
if phase == "decomposition":
|
||||
print("→ Generate DECOMPOSITION.md, then transition to awaiting_approval:")
|
||||
print(f" python ~/.automaton/scripts/status.py --transition decomposition:awaiting_approval --task {task['name']} --project {project_dir}")
|
||||
print(
|
||||
f" python ~/.automaton/scripts/status.py --transition decomposition:awaiting_approval --task {task['name']} --project {project_dir}"
|
||||
)
|
||||
elif phase == "decomposition:approved":
|
||||
print("→ Create sub-tasks from DECOMPOSITION.md, then complete parent:")
|
||||
print(f" python ~/.automaton/scripts/status.py --transition complete --task {task['name']} --project {project_dir}")
|
||||
print(
|
||||
f" python ~/.automaton/scripts/status.py --transition complete --task {task['name']} --project {project_dir}"
|
||||
)
|
||||
elif base == "design":
|
||||
if phase == "design":
|
||||
print("→ Generate DESIGN.md, then transition to awaiting_approval:")
|
||||
print(f" python ~/.automaton/scripts/status.py --transition design:awaiting_approval --task {task['name']} --project {project_dir}")
|
||||
print(
|
||||
f" python ~/.automaton/scripts/status.py --transition design:awaiting_approval --task {task['name']} --project {project_dir}"
|
||||
)
|
||||
elif phase == "design:approved":
|
||||
print("→ Transition to test_design or implement:")
|
||||
print(f" python ~/.automaton/scripts/status.py --transition test_design --task {task['name']} --project {project_dir}")
|
||||
print(
|
||||
f" python ~/.automaton/scripts/status.py --transition test_design --task {task['name']} --project {project_dir}"
|
||||
)
|
||||
elif base == "test_design":
|
||||
if phase == "test_design":
|
||||
print("→ Generate TEST_PLAN.md, then transition to awaiting_approval:")
|
||||
print(f" python ~/.automaton/scripts/status.py --transition test_design:awaiting_approval --task {task['name']} --project {project_dir}")
|
||||
print(
|
||||
f" python ~/.automaton/scripts/status.py --transition test_design:awaiting_approval --task {task['name']} --project {project_dir}"
|
||||
)
|
||||
elif phase == "test_design:approved":
|
||||
print("→ Transition to implement:")
|
||||
print(f" python ~/.automaton/scripts/status.py --transition implement --task {task['name']} --project {project_dir}")
|
||||
print(
|
||||
f" python ~/.automaton/scripts/status.py --transition implement --task {task['name']} --project {project_dir}"
|
||||
)
|
||||
elif base == "implement":
|
||||
print("→ Write implementation, generate IMPLEMENTATION.md, then transition:")
|
||||
print(f" python ~/.automaton/scripts/status.py --transition bug_find --task {task['name']} --project {project_dir}")
|
||||
print(
|
||||
f" python ~/.automaton/scripts/status.py --transition bug_find --task {task['name']} --project {project_dir}"
|
||||
)
|
||||
elif base == "bug_find":
|
||||
print("→ Generate BUG_REPORT.md, then transition:")
|
||||
print(f" python ~/.automaton/scripts/status.py --transition adversarial_bug_find --task {task['name']} --project {project_dir}")
|
||||
print(
|
||||
f" python ~/.automaton/scripts/status.py --transition adversarial_bug_find --task {task['name']} --project {project_dir}"
|
||||
)
|
||||
elif base == "adversarial_bug_find":
|
||||
print("→ Generate ADVERSARIAL_BUG_REPORT.md, then transition:")
|
||||
print(f" python ~/.automaton/scripts/status.py --transition doc_review --task {task['name']} --project {project_dir}")
|
||||
print(
|
||||
f" python ~/.automaton/scripts/status.py --transition doc_review --task {task['name']} --project {project_dir}"
|
||||
)
|
||||
elif base == "doc_review":
|
||||
print("→ Generate DOC_REVIEW.md, then transition:")
|
||||
print(f" python ~/.automaton/scripts/status.py --transition referee --task {task['name']} --project {project_dir}")
|
||||
print(
|
||||
f" python ~/.automaton/scripts/status.py --transition referee --task {task['name']} --project {project_dir}"
|
||||
)
|
||||
elif base == "referee":
|
||||
print("→ Generate VERDICT.md, then transition to complete:")
|
||||
print(f" python ~/.automaton/scripts/status.py --transition complete --task {task['name']} --project {project_dir}")
|
||||
print(
|
||||
f" python ~/.automaton/scripts/status.py --transition complete --task {task['name']} --project {project_dir}"
|
||||
)
|
||||
elif base == "human_intervention":
|
||||
print("→ Task needs human intervention. Review and transition to referee or complete:")
|
||||
print(f" python ~/.automaton/scripts/status.py --transition referee --task {task['name']} --project {project_dir}")
|
||||
print(
|
||||
"→ Task needs human intervention. Review and transition to referee or complete:"
|
||||
)
|
||||
print(
|
||||
f" python ~/.automaton/scripts/status.py --transition referee --task {task['name']} --project {project_dir}"
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
@@ -294,6 +356,7 @@ def cmd_loop(args):
|
||||
return result
|
||||
if args.delay:
|
||||
import time as tm
|
||||
|
||||
tm.sleep(args.delay)
|
||||
print(f"Reached max iterations ({max_iterations}).")
|
||||
return 0
|
||||
@@ -313,7 +376,7 @@ def cmd_stuck(args):
|
||||
for t in stuck:
|
||||
print(f" {t['name']}: stuck at '{t['phase']}' for {t['age_minutes']} min")
|
||||
print(f" Path: {t['path']}")
|
||||
print(f" Action: Review and transition manually or mark complete")
|
||||
print(" Action: Review and transition manually or mark complete")
|
||||
print()
|
||||
print(f"Total stuck: {len(stuck)}")
|
||||
return 0
|
||||
@@ -322,13 +385,25 @@ def cmd_stuck(args):
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Automaton autopilot runtime")
|
||||
parser.add_argument("--project", help="Project root directory")
|
||||
parser.add_argument("--drive", action="store_true", help="Drive one step forward (default)")
|
||||
parser.add_argument(
|
||||
"--drive", action="store_true", help="Drive one step forward (default)"
|
||||
)
|
||||
parser.add_argument("--summary", action="store_true", help="Show autopilot summary")
|
||||
parser.add_argument("--stuck", action="store_true", dest="detect_stuck", help="Detect stuck tasks")
|
||||
parser.add_argument(
|
||||
"--stuck", action="store_true", dest="detect_stuck", help="Detect stuck tasks"
|
||||
)
|
||||
parser.add_argument("--loop", action="store_true", help="Run continuous drive loop")
|
||||
parser.add_argument("--max-iterations", type=int, help="Max iterations for --loop (default: 100)")
|
||||
parser.add_argument("--delay", type=int, help="Delay seconds between iterations for --loop")
|
||||
parser.add_argument("--threshold", type=int, help="Stuck detection threshold in minutes (default: 60)")
|
||||
parser.add_argument(
|
||||
"--max-iterations", type=int, help="Max iterations for --loop (default: 100)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delay", type=int, help="Delay seconds between iterations for --loop"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--threshold",
|
||||
type=int,
|
||||
help="Stuck detection threshold in minutes (default: 60)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
+43
-9
@@ -6,9 +6,22 @@ FRAMEWORK_DIR="$HOME/.automaton"
|
||||
if [ -d "$FRAMEWORK_DIR" ]; then
|
||||
echo "automaton already installed at $FRAMEWORK_DIR"
|
||||
echo "Run './update.sh' to update."
|
||||
else
|
||||
echo "Cloning automaton to $FRAMEWORK_DIR..."
|
||||
git clone http://10.37.0.86:3003/hermes/automaton "$FRAMEWORK_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
GIT_URL="${1:-}"
|
||||
if [ -z "$GIT_URL" ]; then
|
||||
echo "ERROR: Git URL required."
|
||||
echo "Usage: ./install.sh <git-url>"
|
||||
echo "Example: ./install.sh https://github.com/user/automaton.git"
|
||||
echo ""
|
||||
echo "The framework is cloned to ~/.automaton. Choose your URL carefully"
|
||||
echo "as it cannot be changed later without reinstalling."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cloning automaton from $GIT_URL to $FRAMEWORK_DIR..."
|
||||
git clone "$GIT_URL" "$FRAMEWORK_DIR"
|
||||
|
||||
echo ""
|
||||
echo "=== VRAM / Context Detection ==="
|
||||
@@ -69,11 +82,32 @@ echo ""
|
||||
# Register pre-edit guards for detected harnesses
|
||||
bash "$FRAMEWORK_DIR/scripts/register-guards.sh"
|
||||
|
||||
fi
|
||||
# Bootstrap self-improvement loop (default-on, D21)
|
||||
python3 "$FRAMEWORK_DIR/scripts/status.py" --create-loop self-improvement \
|
||||
--from-template self-improvement --project "$FRAMEWORK_DIR" || true
|
||||
python3 "$FRAMEWORK_DIR/scripts/status.py" --install-schedule self-improvement \
|
||||
--interval 3600 --project "$FRAMEWORK_DIR" || true
|
||||
|
||||
# --- Python deps (idempotent) ---
|
||||
if [ ! -d ".venv" ]; then
|
||||
python3 -m venv .venv
|
||||
echo ""
|
||||
echo "=== Self-Improvement Loop ==="
|
||||
echo "A self-improvement loop has been created and scheduled (runs every 3600s)."
|
||||
echo "It will tick against status.py --audit on this framework's own repo."
|
||||
echo "To disable: python3 ~/.automaton/scripts/status.py --pause-loop self-improvement --project ~/.automaton/"
|
||||
echo ""
|
||||
|
||||
# Verify framework is working
|
||||
python3 "$FRAMEWORK_DIR/scripts/status.py" --version || echo "WARNING: status.py --version failed"
|
||||
|
||||
# --- Python deps (idempotent, in framework dir) ---
|
||||
if [ ! -d "$FRAMEWORK_DIR/.venv" ]; then
|
||||
python3 -m venv "$FRAMEWORK_DIR/.venv"
|
||||
fi
|
||||
.venv/bin/pip install --quiet --upgrade pip
|
||||
.venv/bin/pip install --quiet -r requirements.txt
|
||||
if [ -f "$FRAMEWORK_DIR/.venv/bin/python3" ]; then
|
||||
VENV_PY="$FRAMEWORK_DIR/.venv/bin/python3"
|
||||
elif [ -f "$FRAMEWORK_DIR/.venv/Scripts/python.exe" ]; then
|
||||
VENV_PY="$FRAMEWORK_DIR/.venv/Scripts/python.exe"
|
||||
else
|
||||
VENV_PY="python3"
|
||||
fi
|
||||
"$VENV_PY" -m pip install --quiet --upgrade pip
|
||||
"$VENV_PY" -m pip install --quiet -r "$FRAMEWORK_DIR/requirements.txt"
|
||||
@@ -0,0 +1,957 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Automaton loop runner -- per-tick engine.
|
||||
|
||||
Invoked by the OS scheduler unit (automaton-loop-tick.sh / .bat generated by
|
||||
`status.py --install-schedule`), or manually, or in --mode daemon.
|
||||
|
||||
Contract (design/loops/technical.md section 7):
|
||||
1. load .state.loop + loop.json
|
||||
2. status.py --check-gate <name> --json; SKIP on not-ok with exit 0
|
||||
3. find_work(work_source dispatch: single/audit/backlog)
|
||||
4. ensure worktree (D2 -- per-loop git worktree at <loop>/worktree,
|
||||
branch loop/<name>; falls back to project root on non-git or failure)
|
||||
5. spawn Implement role via loop.json harness.command
|
||||
6. spawn Verify role via harness.command; output is graded JSON verdict
|
||||
7. parse verdict (accepts raw JSON, fenced JSON blocks, line comments)
|
||||
parse failure -> halt verifier_failed, exit 0
|
||||
8. cap score_history at brakes.score_plateau_window
|
||||
9. spawn Orchestrate role (the orchestrator calls status.py itself; runner
|
||||
does not parse orchestrator output)
|
||||
10. write iteration_count++, last_tick_at, last_verdict atomically
|
||||
10.5. GC outputs/ (retain last N tick groups per outputs.retention, v1.1)
|
||||
11. append TICK line to .state.log
|
||||
|
||||
Idempotent in failure: anything that fails before step 10 leaves .state.loop
|
||||
unchanged. Harness subprocesses are not owned; runner does not kill process
|
||||
groups in v1.
|
||||
|
||||
Stdlib only; no new pip deps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, Sequence
|
||||
|
||||
AUTOMATON_DIR = Path.home() / ".automaton"
|
||||
STATUS_SCRIPT = AUTOMATON_DIR / "scripts" / "status.py"
|
||||
VRAM_SCRIPT = AUTOMATON_DIR / "scripts" / "vram_detect.py"
|
||||
|
||||
LOOP_STATE_FILE = ".state.loop"
|
||||
LOOP_CONFIG_FILE = "loop.json"
|
||||
LOOP_TICK_LOG_NAME = ".state.log"
|
||||
LOOP_OUTPUTS_DIR = "outputs"
|
||||
LOOP_WORKTREE_DIR = "worktree"
|
||||
LOOP_STATE_SCHEMA_VERSION = 1
|
||||
CONTEXT_FLOOR_KB = 16_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Small duplicated helpers (kept local rather than imported across scripts;
|
||||
# see technical.md "no cross-script imports")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_project_dir(project: Optional[str]) -> Path:
|
||||
if project:
|
||||
p = Path(project).resolve()
|
||||
return p
|
||||
cwd = Path.cwd().resolve()
|
||||
if cwd == AUTOMATON_DIR:
|
||||
return AUTOMATON_DIR
|
||||
if (cwd / ".automaton").exists():
|
||||
return cwd
|
||||
if cwd.parent == AUTOMATON_DIR:
|
||||
return AUTOMATON_DIR
|
||||
print(f"ERROR: not in an automaton project directory (cwd={cwd}). "
|
||||
f"Use --project to specify the project path.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _loops_dir(project_dir: Path) -> Path:
|
||||
if project_dir == AUTOMATON_DIR:
|
||||
return AUTOMATON_DIR / "loops"
|
||||
return project_dir / ".automaton" / "loops"
|
||||
|
||||
|
||||
def _loop_dir(name: str, project_dir: Path) -> Path:
|
||||
return _loops_dir(project_dir) / name
|
||||
|
||||
|
||||
def _read_state_loop(loop_path: Path) -> Optional[dict]:
|
||||
f = loop_path / LOOP_STATE_FILE
|
||||
if not f.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(f.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_state_loop(loop_path: Path, state: dict) -> None:
|
||||
f = loop_path / LOOP_STATE_FILE
|
||||
tmp = loop_path / ".state.loop.tmp"
|
||||
tmp.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
|
||||
tmp.replace(f)
|
||||
|
||||
|
||||
def _read_loop_config(loop_path: Path) -> Optional[dict]:
|
||||
f = loop_path / LOOP_CONFIG_FILE
|
||||
if not f.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(f.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _append_tick_log(loop_path: Path, line: str) -> None:
|
||||
log = loop_path / LOOP_TICK_LOG_NAME
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
with log.open("a", encoding="utf-8") as fh:
|
||||
fh.write(f"[{ts}] {line}\n")
|
||||
|
||||
|
||||
def _halt_loop(loop_path: Path, state: dict, reason: str) -> None:
|
||||
state["status"] = "halted"
|
||||
state["halt_reason"] = reason
|
||||
_write_state_loop(loop_path, state)
|
||||
_append_tick_log(loop_path, f"HALT reason={reason}")
|
||||
|
||||
|
||||
_LOOP_LOCK_ENV_BYPASS = "AUTOMATON_NO_LOOP_LOCK"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _loop_lock(loop_path: Path, exclusive: bool = True):
|
||||
"""Cross-process file lock on <loop_path>/.state.lock held for the full tick.
|
||||
|
||||
Serializes the runner's read-modify-write cycle on `.state.loop` against
|
||||
concurrent ticks (two scheduler firings on the same loop) and concurrent
|
||||
`status.py --pause-loop` / --approve --loop writes. Blocking acquire;
|
||||
no timeout in v1.1 (operators notice a wedged tick via `--loop-list`
|
||||
stale `last_tick_at`).
|
||||
|
||||
Per-loop granularity: lock file lives in the loop's own dir, not the
|
||||
framework root. A lock on loop A's tick does not block loop B.
|
||||
|
||||
The runner holds this lock across `_gate` (subprocess), the harness
|
||||
subprocess (Implement/Verify/Orchestrate), and the state write (step 10).
|
||||
`_gate` spawns `status.py --check-gate` which would otherwise deadlock
|
||||
waiting on the same flock; to avoid this the runner passes
|
||||
$AUTOMATON_NO_LOOP_LOCK=1 in that subprocess env, and `status.py`'s own
|
||||
`_loop_lock` becomes a no-op that trusts the parent's outer lock.
|
||||
|
||||
NOT re-entrant: do not nest `_loop_lock` within itself. POSIX `flock` is
|
||||
per-fd-per-process; a second runner process blocks cleanly until the
|
||||
first releases.
|
||||
|
||||
NFS caveat: `flock` semantics differ on NFS-mounted loop dirs. The loop
|
||||
dir is documented to be local (project root or `~/.automaton`).
|
||||
"""
|
||||
lock_file = loop_path / ".state.lock"
|
||||
fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT, 0o644)
|
||||
acquired = False
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
msvcrt.locking(fd, msvcrt.LK_LOCK if exclusive else msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
fcntl.flock(fd, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
|
||||
acquired = True
|
||||
yield
|
||||
finally:
|
||||
if acquired:
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
try:
|
||||
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
import fcntl
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
os.close(fd)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# subprocess plumbing (testable via monkeypatch of subprocess.run)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_json(args: Sequence[str], env: Optional[dict] = None) -> Optional[dict]:
|
||||
"""Run a subprocess, return parsed JSON or None."""
|
||||
try:
|
||||
res = subprocess.run(list(args), capture_output=True, text=True,
|
||||
timeout=30, env=env)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
print(f"ERROR: subprocess {args[0]!r} failed: {exc}", file=sys.stderr)
|
||||
return None
|
||||
if res.returncode != 0:
|
||||
return None
|
||||
out = res.stdout.strip()
|
||||
if not out:
|
||||
return None
|
||||
try:
|
||||
return json.loads(out.splitlines()[-1])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def _substitute(template: str, mapping: dict) -> str:
|
||||
out = template
|
||||
for key, value in mapping.items():
|
||||
out = out.replace("{" + key + "}", str(value))
|
||||
return out
|
||||
|
||||
|
||||
_TRUNCATE_MARKER = " …[truncated]"
|
||||
|
||||
|
||||
def _truncate_tokens(text: str, max_tokens: int) -> str:
|
||||
"""Approximate token cap (4 chars/token heuristic, stdlib only)."""
|
||||
if not text:
|
||||
return ""
|
||||
if max_tokens <= 0:
|
||||
return ""
|
||||
char_budget = max_tokens * 4
|
||||
if len(text) <= char_budget:
|
||||
return text
|
||||
return text[: char_budget - len(_TRUNCATE_MARKER)] + _TRUNCATE_MARKER
|
||||
|
||||
|
||||
def _read_task_brief(task_dir: Path) -> str:
|
||||
"""Pick the first existing phase doc to feed the verifier as {task_brief}."""
|
||||
for name in ("RESEARCH.md", "DESIGN.md", "SPEC.md"):
|
||||
f = task_dir / name
|
||||
if f.exists():
|
||||
try:
|
||||
return f.read_text()
|
||||
except OSError:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def _acceptance_criteria_text(cfg: dict) -> str:
|
||||
raw = cfg.get("acceptance_criteria")
|
||||
if raw is None:
|
||||
return ""
|
||||
if isinstance(raw, list):
|
||||
return "\n".join(str(x) for x in raw)
|
||||
return str(raw)
|
||||
|
||||
|
||||
def _next_hint_text(state: dict) -> str:
|
||||
last = state.get("last_verdict")
|
||||
if not isinstance(last, dict):
|
||||
return ""
|
||||
return str(last.get("next_hint") or "")
|
||||
|
||||
|
||||
def _git_run(args_list: list, cwd: str, timeout: int = 15) -> tuple[int, str, str]:
|
||||
try:
|
||||
res = subprocess.run(["git"] + args_list, cwd=cwd,
|
||||
capture_output=True, text=True, timeout=timeout, check=False)
|
||||
return res.returncode, res.stdout.strip(), res.stderr.strip()
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return -1, "", str(exc)
|
||||
|
||||
|
||||
def _ensure_worktree(state: dict, cfg: dict, loop_path: Path, project_dir: Path) -> str:
|
||||
blast = cfg.get("blast_radius") or {}
|
||||
use_worktree = blast.get("use_worktree", True)
|
||||
if not use_worktree:
|
||||
return str(project_dir)
|
||||
|
||||
existing = state.get("worktree_path")
|
||||
if existing and Path(existing).exists():
|
||||
return existing
|
||||
|
||||
if existing and not Path(existing).exists():
|
||||
state["worktree_path"] = None
|
||||
state["worktree_branch"] = None
|
||||
|
||||
wt_path = loop_path / LOOP_WORKTREE_DIR
|
||||
loop_name = state.get("name") or loop_path.name
|
||||
branch = f"loop/{loop_name}"
|
||||
|
||||
rc, out, err = _git_run(["rev-parse", "--is-inside-work-tree"], str(project_dir))
|
||||
if rc != 0 or out != "true":
|
||||
_append_tick_log(loop_path, f"WARNING worktree skipped: not a git repo ({err})")
|
||||
return str(project_dir)
|
||||
|
||||
rc, out, err = _git_run(["worktree", "add", str(wt_path), "-b", branch], str(project_dir))
|
||||
if rc != 0:
|
||||
if "already exists" in err or "exists" in err:
|
||||
rc, out, err = _git_run(["worktree", "add", str(wt_path), branch], str(project_dir))
|
||||
if rc != 0:
|
||||
_append_tick_log(loop_path, f"WARNING worktree add failed: {err}")
|
||||
return str(project_dir)
|
||||
|
||||
state["worktree_path"] = str(wt_path)
|
||||
state["worktree_branch"] = branch
|
||||
_write_state_loop(loop_path, state)
|
||||
return str(wt_path)
|
||||
|
||||
|
||||
def _resolve_prompt(prompt_ref: Optional[str], extras: Optional[dict],
|
||||
loop_path: Path, tick_num: int, role: str) -> str:
|
||||
"""Resolve a prompt reference to a file path with tokens substituted.
|
||||
|
||||
Searches <loop_path>/<prompt_ref> then ~/.automaton/prompts/<prompt_ref>.
|
||||
Reads the file, substitutes content-level tokens ({task_brief},
|
||||
{acceptance_criteria}, {next_hint}, {current_task}, {current_phase},
|
||||
{verdict}, {artifact_content}), writes to a temp file in outputs/, and
|
||||
returns the temp file path. Falls back to the raw prompt_ref if the file
|
||||
is not found.
|
||||
"""
|
||||
if not prompt_ref:
|
||||
return prompt_ref or ""
|
||||
|
||||
candidates = [
|
||||
loop_path / prompt_ref,
|
||||
AUTOMATON_DIR / "prompts" / prompt_ref,
|
||||
]
|
||||
src_path = None
|
||||
for c in candidates:
|
||||
if c.exists():
|
||||
src_path = c
|
||||
break
|
||||
if src_path is None:
|
||||
return prompt_ref
|
||||
|
||||
try:
|
||||
content = src_path.read_text()
|
||||
except OSError:
|
||||
return prompt_ref
|
||||
|
||||
content_extras = {}
|
||||
if extras:
|
||||
for k in ("task_brief", "acceptance_criteria", "next_hint",
|
||||
"current_task", "current_phase", "verdict"):
|
||||
if k in extras:
|
||||
content_extras[k] = extras[k]
|
||||
|
||||
if "{artifact_content}" in content:
|
||||
artifact_path = extras.get("artifact") if extras else None
|
||||
artifact_content = ""
|
||||
if artifact_path:
|
||||
try:
|
||||
artifact_content = Path(artifact_path).read_text()
|
||||
except OSError:
|
||||
artifact_content = ""
|
||||
content_extras["artifact_content"] = artifact_content
|
||||
|
||||
for key, value in content_extras.items():
|
||||
content = content.replace("{" + key + "}", str(value))
|
||||
|
||||
out_dir = loop_path / LOOP_OUTPUTS_DIR
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp_prompt = out_dir / f"tick{tick_num}-{role}-prompt.md"
|
||||
tmp_prompt.write_text(content)
|
||||
return str(tmp_prompt)
|
||||
|
||||
|
||||
def _invoke_harness(
|
||||
harness_cfg: Optional[dict],
|
||||
role: str,
|
||||
prompt_path: str,
|
||||
cwd: str,
|
||||
extras: Optional[dict] = None,
|
||||
loop_path: Optional[Path] = None,
|
||||
tick_num: int = 0,
|
||||
) -> str:
|
||||
"""Build the harness command from loop.json and invoke it. Returns stdout.
|
||||
|
||||
extras: substitution tokens specific to this role ({artifact}, {verdict}, etc).
|
||||
"""
|
||||
resolved_prompt = prompt_path
|
||||
if loop_path is not None:
|
||||
resolved_prompt = _resolve_prompt(prompt_path, extras, loop_path, tick_num, role)
|
||||
|
||||
prompt_content = ""
|
||||
try:
|
||||
prompt_content = Path(resolved_prompt).read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
prompt_content = ""
|
||||
|
||||
if harness_cfg is None:
|
||||
command = ["opencode", "run", "--dir", "{cwd}", "{prompt_content}"]
|
||||
else:
|
||||
command = list(harness_cfg.get("command") or [])
|
||||
if not command:
|
||||
command = ["opencode", "run", "--dir", "{cwd}", "{prompt_content}"]
|
||||
mapping = {"prompt": resolved_prompt, "cwd": cwd, "prompt_content": prompt_content}
|
||||
if extras:
|
||||
mapping.update(extras)
|
||||
final_argv = [_substitute(tok, mapping) for tok in command]
|
||||
try:
|
||||
res = subprocess.run(final_argv, capture_output=True, text=True, cwd=cwd)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
print(f"ERROR: harness invocation failed for role {role!r}: {exc}", file=sys.stderr)
|
||||
return ""
|
||||
return res.stdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verdict parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
|
||||
|
||||
|
||||
def _strip_comments(text: str) -> str:
|
||||
"""Remove // and # leading-comment lines (cheap, sufficient for v1)."""
|
||||
kept = []
|
||||
for raw in text.splitlines():
|
||||
s = raw.lstrip()
|
||||
if s.startswith("//") or s.startswith("#"):
|
||||
continue
|
||||
kept.append(raw)
|
||||
return "\n".join(kept)
|
||||
|
||||
|
||||
def parse_verdict(text: str) -> Optional[dict]:
|
||||
"""Parse verifier JSON verdict. Accepts raw, fenced, or commented JSON.
|
||||
|
||||
Required keys: pass (bool — also accepts "true"/"false" strings,
|
||||
case-insensitive), score (float — clamped to [0, 1]; NaN / non-finite
|
||||
values default to 0.5; non-numeric values default to 0.5). Optional:
|
||||
reasons (list[str]), next_hint (str). Returns None on parse failure.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
candidates = []
|
||||
fence_match = _FENCE_RE.search(text)
|
||||
if fence_match:
|
||||
candidates.append(fence_match.group(1))
|
||||
candidates.append(text)
|
||||
for body in candidates:
|
||||
body = _strip_comments(body).strip()
|
||||
if not body:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if "pass" not in data:
|
||||
continue
|
||||
raw_pass = data.get("pass")
|
||||
if isinstance(raw_pass, str):
|
||||
lower = raw_pass.strip().lower()
|
||||
if lower == "true":
|
||||
verdict_pass = True
|
||||
elif lower == "false":
|
||||
verdict_pass = False
|
||||
else:
|
||||
verdict_pass = bool(raw_pass.strip())
|
||||
else:
|
||||
verdict_pass = bool(raw_pass)
|
||||
try:
|
||||
score = float(data.get("score", 0.0))
|
||||
except (TypeError, ValueError):
|
||||
score = 0.5
|
||||
if not math.isfinite(score):
|
||||
score = 0.5
|
||||
score = max(0.0, min(1.0, score))
|
||||
verdict = {
|
||||
"pass": verdict_pass,
|
||||
"score": score,
|
||||
}
|
||||
if "reasons" in data and isinstance(data["reasons"], list):
|
||||
verdict["reasons"] = [str(r) for r in data["reasons"]]
|
||||
else:
|
||||
verdict["reasons"] = []
|
||||
if "next_hint" in data and isinstance(data["next_hint"], str):
|
||||
verdict["next_hint"] = data["next_hint"]
|
||||
return verdict
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tick
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _gate(loop_path: Path, loop_name: str, project_dir: Path) -> Optional[dict]:
|
||||
"""Call status.py --check-gate; return the parsed JSON dict or None on subprocess error.
|
||||
|
||||
Sets $AUTOMATON_NO_LOOP_LOCK=1 in the subprocess env so status.py's
|
||||
`_loop_lock` becomes a no-op. The runner is responsible for holding an
|
||||
outer `_loop_lock` around the entire tick; if status.py also tried to
|
||||
flock the same `.state.lock` it would deadlock waiting on the parent's
|
||||
flock. The env var is scoped to this subprocess only — harness
|
||||
subprocesses (Implement/Verify/Orchestrate) do NOT inherit it, so any
|
||||
`status.py --transition` calls the harness makes lock normally.
|
||||
"""
|
||||
args = [sys.executable, str(STATUS_SCRIPT),
|
||||
"--check-gate", loop_name,
|
||||
"--project", str(project_dir),
|
||||
"--json"]
|
||||
env = {**os.environ, _LOOP_LOCK_ENV_BYPASS: "1"}
|
||||
out = _run_json(args, env=env)
|
||||
if out is None:
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
def _context_floor_ok() -> bool:
|
||||
"""Return True iff vram_detect.py reports loop-mode eligibility."""
|
||||
args = [sys.executable, str(VRAM_SCRIPT), "--loop-mode", "--json"]
|
||||
out = _run_json(args)
|
||||
if out is None:
|
||||
return True # best-effort: if the tool is unavailable, allow the tick
|
||||
return bool(out.get("loop_mode_eligible", True))
|
||||
|
||||
|
||||
def _role_prompt(cfg: dict, role: str) ->Optional[str]:
|
||||
roles = cfg.get("roles") or {}
|
||||
role_cfg = roles.get(role) or {}
|
||||
return role_cfg.get("prompt")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Work sources (task add-goal-mode)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_SEVERITY_RANK = {"high": 3, "med": 2, "low": 1}
|
||||
|
||||
|
||||
def _task_dir_for(name: str, project_dir: Path) -> Path:
|
||||
"""Local mirror of status.py _task_dir (no cross-script imports)."""
|
||||
if project_dir == AUTOMATON_DIR:
|
||||
base = AUTOMATON_DIR / "tasks"
|
||||
else:
|
||||
base = project_dir / ".automaton" / "tasks"
|
||||
return base / name
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
s = re.sub(r"[^A-Za-z0-9._-]+", "-", text.strip().lower())
|
||||
s = re.sub(r"-+", "-", s).strip("-")
|
||||
return s or "task"
|
||||
|
||||
|
||||
def _find_work_single(state: dict, cfg: dict, loop_path: Path, project_dir: Path) -> Optional[str]:
|
||||
"""Return current_task or None; unchanged from task 3 behaviour."""
|
||||
return state.get("current_task")
|
||||
|
||||
|
||||
def _find_work_audit(state: dict, cfg: dict, loop_path: Path, project_dir: Path) -> Optional[str]:
|
||||
"""Run status.py --audit --json; pick the highest-severity unresolved violation."""
|
||||
ws = cfg.get("work_source") or {}
|
||||
audit_project = ws.get("project") or str(project_dir)
|
||||
args = [sys.executable, str(STATUS_SCRIPT),
|
||||
"--audit",
|
||||
"--project", audit_project,
|
||||
"--json"]
|
||||
out = _run_json(args)
|
||||
if not out:
|
||||
return None
|
||||
violations = out.get("violations") or []
|
||||
unresolved = [v for v in violations if not v.get("resolved", False)]
|
||||
if not unresolved:
|
||||
return None
|
||||
unresolved.sort(key=lambda v: _SEVERITY_RANK.get(v.get("severity", ""), 0), reverse=True)
|
||||
top = unresolved[0]
|
||||
task = top.get("task")
|
||||
if task:
|
||||
return task
|
||||
msg = top.get("message") or "audit-violation"
|
||||
slug = _slugify(msg)
|
||||
if _task_dir_for(slug, project_dir).exists():
|
||||
return slug
|
||||
create_args = [sys.executable, str(STATUS_SCRIPT),
|
||||
"--create-task", slug,
|
||||
"--project", str(project_dir)]
|
||||
try:
|
||||
subprocess.run(create_args, capture_output=True, text=True, timeout=15)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
return slug
|
||||
|
||||
|
||||
def _find_work_backlog(state: dict, cfg: dict, loop_path: Path, project_dir: Path) -> Optional[str]:
|
||||
"""Read design/<area>/BACKLOG.md; pick the topmost `- [ ]` item."""
|
||||
ws = cfg.get("work_source") or {}
|
||||
area = ws.get("area") or "loops"
|
||||
if project_dir == AUTOMATON_DIR:
|
||||
backlog = AUTOMATON_DIR / "design" / area / "BACKLOG.md"
|
||||
else:
|
||||
backlog = project_dir / "design" / area / "BACKLOG.md"
|
||||
if not backlog.exists():
|
||||
return None
|
||||
try:
|
||||
text = backlog.read_text()
|
||||
except OSError:
|
||||
return None
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("- [ ]"):
|
||||
m = re.search(r"\*\*([A-Za-z0-9._-]+)\*\*", stripped)
|
||||
if m:
|
||||
return m.group(1)
|
||||
body = stripped.replace("- [ ]", "", 1).strip()
|
||||
return _slugify(body)
|
||||
return None
|
||||
|
||||
|
||||
_FIND_WORK_DISPATCH = {
|
||||
"single": _find_work_single,
|
||||
"audit": _find_work_audit,
|
||||
"backlog": _find_work_backlog,
|
||||
}
|
||||
|
||||
|
||||
def _find_work(state: dict, cfg: dict, loop_path: Path, project_dir: Path) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Return (current_task, skip_reason). skip_reason is None when work was found.
|
||||
|
||||
Unknown/missing work_source falls back to 'single' with a WARNING log line.
|
||||
"""
|
||||
ws = cfg.get("work_source") or {}
|
||||
kind = ws.get("kind") if isinstance(ws, dict) else None
|
||||
if not kind:
|
||||
kind = "single"
|
||||
handler = _FIND_WORK_DISPATCH.get(kind)
|
||||
if handler is None:
|
||||
_append_tick_log(loop_path, f"WARNING unknown work_source.kind={kind!r}; falling back to single")
|
||||
handler = _find_work_single
|
||||
kind = "single"
|
||||
task = handler(state, cfg, loop_path, project_dir)
|
||||
if task is None:
|
||||
if kind == "single":
|
||||
return None, "no_current_task"
|
||||
return None, "no_work"
|
||||
return task, None
|
||||
|
||||
|
||||
def _score_window(cfg: dict) -> int:
|
||||
return int((cfg.get("brakes") or {}).get("score_plateau_window", 0) or 0)
|
||||
|
||||
|
||||
def _loop_max_iterations(cfg: dict) -> int:
|
||||
return int((cfg.get("brakes") or {}).get("max_iterations", 0) or 0)
|
||||
|
||||
|
||||
def _outputs_dir(loop_path: Path) -> Path:
|
||||
d = loop_path / LOOP_OUTPUTS_DIR
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _get_retention(cfg: Optional[dict]) -> int:
|
||||
if cfg is None:
|
||||
return 20
|
||||
try:
|
||||
raw = (cfg.get("outputs") or {}).get("retention", 20)
|
||||
ret = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
print(f"WARNING: outputs.retention={raw!r} is not an int; falling back to 20",
|
||||
file=sys.stderr)
|
||||
return 20
|
||||
if ret < 0:
|
||||
print(f"WARNING: outputs.retention={ret} is negative; treating as 0 (unlimited)",
|
||||
file=sys.stderr)
|
||||
return 0
|
||||
return ret
|
||||
|
||||
|
||||
def _gc_outputs(loop_path: Path, retention: int) -> None:
|
||||
if retention <= 0:
|
||||
return
|
||||
out_dir = loop_path / LOOP_OUTPUTS_DIR
|
||||
if not out_dir.exists():
|
||||
return
|
||||
try:
|
||||
names = os.listdir(str(out_dir))
|
||||
except OSError:
|
||||
return
|
||||
tick_re = re.compile(r"^tick(\d+)-")
|
||||
max_seen = 0
|
||||
for name in names:
|
||||
m = tick_re.match(name)
|
||||
if m:
|
||||
idx = int(m.group(1))
|
||||
if idx > max_seen:
|
||||
max_seen = idx
|
||||
if max_seen == 0:
|
||||
return
|
||||
cutoff = max_seen - retention + 1
|
||||
for name in names:
|
||||
m = tick_re.match(name)
|
||||
if m:
|
||||
idx = int(m.group(1))
|
||||
if idx < cutoff:
|
||||
try:
|
||||
(out_dir / name).unlink()
|
||||
except OSError as exc:
|
||||
_append_tick_log(loop_path,
|
||||
f"WARNING GC failed to remove {name}: {exc}")
|
||||
|
||||
|
||||
def cmd_tick(args, runner_state: Optional[dict] = None) -> dict:
|
||||
"""Execute one tick. Returns a summary dict (used both for --json output
|
||||
and for daemon-mode bookkeeping).
|
||||
|
||||
`runner_state` is reserved for daemon mode to accumulate state across ticks.
|
||||
|
||||
The full read-modify-write cycle on `.state.loop` (from `_gate` through
|
||||
step 10's state write) is wrapped in `_loop_lock(loop_path)` so that
|
||||
concurrent ticks (two scheduler firings on the same loop) and concurrent
|
||||
`status.py --pause-loop` / --approve --loop writes serialize rather than
|
||||
overwriting each other. See `_loop_lock` docstring for the env-bypass
|
||||
mechanism used to avoid deadlock with the `--check-gate` subprocess.
|
||||
"""
|
||||
project_dir = _find_project_dir(args.project)
|
||||
loop_path = _loop_dir(args.loop, project_dir)
|
||||
state = _read_state_loop(loop_path)
|
||||
summary = {"loop": args.loop, "skipped": False, "halted": False, "reason": None,
|
||||
"iter": None, "verdict": None}
|
||||
|
||||
if state is None:
|
||||
_append_tick_log(loop_path, "SKIP untracked")
|
||||
summary["reason"] = "untracked"
|
||||
summary["skipped"] = True
|
||||
return summary
|
||||
|
||||
cfg = _read_loop_config(loop_path) or {}
|
||||
|
||||
with _loop_lock(loop_path):
|
||||
# Re-read fresh state under the lock; a concurrent --pause / --approve
|
||||
# may have mutated it between the unlocked read above and here.
|
||||
state = _read_state_loop(loop_path)
|
||||
if state is None:
|
||||
_append_tick_log(loop_path, "SKIP untracked")
|
||||
summary["reason"] = "untracked"
|
||||
summary["skipped"] = True
|
||||
return summary
|
||||
|
||||
# Step 2: gate
|
||||
gate = _gate(loop_path, args.loop, project_dir)
|
||||
if gate is None:
|
||||
_append_tick_log(loop_path, "SKIP gate_subprocess_failed")
|
||||
summary["reason"] = "gate_subprocess_failed"
|
||||
summary["skipped"] = True
|
||||
return summary
|
||||
if not gate.get("ok"):
|
||||
reason = gate.get("reason") or "not_ok"
|
||||
_append_tick_log(loop_path, f"SKIP reason={reason}")
|
||||
summary["reason"] = reason
|
||||
summary["skipped"] = True
|
||||
return summary
|
||||
|
||||
# Step 3: find work (R1 -- dispatch on work_source.kind)
|
||||
current_task, skip_reason = _find_work(state, cfg, loop_path, project_dir)
|
||||
if current_task is None:
|
||||
_append_tick_log(loop_path, f"SKIP {skip_reason}")
|
||||
summary["reason"] = skip_reason
|
||||
summary["skipped"] = True
|
||||
return summary
|
||||
|
||||
# Step 3.5: claim task (R2 -- cross-loop ownership check)
|
||||
if current_task != state.get("current_task"):
|
||||
claim_env = {**os.environ, _LOOP_LOCK_ENV_BYPASS: "1"}
|
||||
claim_args = [sys.executable, str(STATUS_SCRIPT),
|
||||
"--claim-loop-task", args.loop,
|
||||
"--task", current_task,
|
||||
"--project", str(project_dir)]
|
||||
try:
|
||||
claim_res = subprocess.run(claim_args, capture_output=True, text=True,
|
||||
timeout=15, env=claim_env)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
_append_tick_log(loop_path, f"SKIP claim_subprocess_failed:{exc}")
|
||||
summary["reason"] = "claim_subprocess_failed"
|
||||
summary["skipped"] = True
|
||||
return summary
|
||||
if claim_res.returncode != 0:
|
||||
msg = claim_res.stderr.strip() or claim_res.stdout.strip() or "denied"
|
||||
_append_tick_log(loop_path, f"SKIP {msg}")
|
||||
summary["reason"] = msg
|
||||
summary["skipped"] = True
|
||||
return summary
|
||||
state["current_task"] = current_task
|
||||
|
||||
# Step 4: ensure worktree exists (D2 -- per-loop git worktree)
|
||||
cwd = _ensure_worktree(state, cfg, loop_path, project_dir)
|
||||
|
||||
# Step R5: context-floor guard (D13)
|
||||
if not _context_floor_ok():
|
||||
_halt_loop(loop_path, state, "human_intervention")
|
||||
_append_tick_log(loop_path, "HALT human_intervention:context_below_floor")
|
||||
summary["reason"] = "context_below_floor"
|
||||
summary["halted"] = True
|
||||
return summary
|
||||
|
||||
# Goal-oriented substitution tokens (R4, R5, R6).
|
||||
task_dir = _task_dir_for(current_task, project_dir)
|
||||
task_brief = _truncate_tokens(_read_task_brief(task_dir), 4000)
|
||||
acceptance = _truncate_tokens(_acceptance_criteria_text(cfg), 2000)
|
||||
next_hint = _truncate_tokens(_next_hint_text(state), 1000)
|
||||
|
||||
# Step 5: spawn Implement
|
||||
implement_prompt = _role_prompt(cfg, "implement") or ""
|
||||
harness_cfg = cfg.get("harness")
|
||||
out_dir = _outputs_dir(loop_path)
|
||||
tick_num = state.get('iteration_count', 0) + 1
|
||||
impl_output = str(out_dir / f"tick{tick_num}-implement.json")
|
||||
implement_stdout = _invoke_harness(
|
||||
harness_cfg, "implement", implement_prompt, cwd,
|
||||
extras={"output": impl_output,
|
||||
"current_task": current_task,
|
||||
"task_brief": task_brief,
|
||||
"acceptance_criteria": acceptance,
|
||||
"next_hint": next_hint},
|
||||
loop_path=loop_path, tick_num=tick_num)
|
||||
(Path(impl_output)).write_text(implement_stdout)
|
||||
|
||||
# Step 6: spawn Verify
|
||||
verify_prompt = _role_prompt(cfg, "verify") or ""
|
||||
verify_output = str(out_dir / f"tick{tick_num}-verify.json")
|
||||
verify_stdout = _invoke_harness(
|
||||
harness_cfg, "verify", verify_prompt, cwd,
|
||||
extras={"output": verify_output,
|
||||
"artifact": impl_output,
|
||||
"current_task": current_task,
|
||||
"task_brief": task_brief,
|
||||
"acceptance_criteria": acceptance,
|
||||
"next_hint": next_hint},
|
||||
loop_path=loop_path, tick_num=tick_num)
|
||||
(Path(verify_output)).write_text(verify_stdout)
|
||||
|
||||
# Step 7: parse verdict
|
||||
verdict = parse_verdict(verify_stdout)
|
||||
if verdict is None:
|
||||
_halt_loop(loop_path, state, "verifier_failed")
|
||||
_append_tick_log(loop_path, "HALT verifier_failed:unparseable")
|
||||
summary["reason"] = "verifier_failed:unparseable"
|
||||
summary["halted"] = True
|
||||
return summary
|
||||
|
||||
# Step 8: cap score_history
|
||||
window = _score_window(cfg)
|
||||
history = list(state.get("score_history", []))
|
||||
history.append(float(verdict.get("score", 0.0)))
|
||||
if window > 0 and len(history) > window:
|
||||
history = history[-window:]
|
||||
state["score_history"] = history
|
||||
state["last_verdict"] = verdict
|
||||
|
||||
# Step 9: spawn Orchestrate
|
||||
orch_prompt = _role_prompt(cfg, "orchestrate") or ""
|
||||
orch_output = str(out_dir / f"tick{tick_num}-orchestrate.json")
|
||||
orch_stdout = _invoke_harness(
|
||||
harness_cfg, "orchestrate", orch_prompt, cwd,
|
||||
extras={"output": orch_output,
|
||||
"verdict": json.dumps(verdict),
|
||||
"current_task": current_task,
|
||||
"current_phase": state.get("current_phase", "")},
|
||||
loop_path=loop_path, tick_num=tick_num)
|
||||
(Path(orch_output)).write_text(orch_stdout)
|
||||
|
||||
# Step 9.5: release on terminal phase (R3)
|
||||
task_state_path = _task_dir_for(current_task, project_dir) / ".state"
|
||||
if task_state_path.exists():
|
||||
try:
|
||||
task_phase = task_state_path.read_text().strip()
|
||||
except OSError:
|
||||
task_phase = ""
|
||||
if task_phase in ("complete", "human_intervention"):
|
||||
state["current_task"] = None
|
||||
_append_tick_log(loop_path,
|
||||
f"RELEASE current_task={current_task} phase={task_phase}")
|
||||
|
||||
# Step 10: advance state (atomic)
|
||||
state["iteration_count"] = int(state.get("iteration_count", 0)) + 1
|
||||
state["last_tick_at"] = datetime.now(timezone.utc).isoformat()
|
||||
_write_state_loop(loop_path, state)
|
||||
|
||||
# Step 10.5: GC outputs
|
||||
retention = _get_retention(cfg)
|
||||
_gc_outputs(loop_path, retention)
|
||||
|
||||
# Step 11: log
|
||||
_append_tick_log(loop_path,
|
||||
f"TICK pass={verdict['pass']} score={verdict['score']} "
|
||||
f"iter={state['iteration_count']}")
|
||||
|
||||
summary["iter"] = state["iteration_count"]
|
||||
summary["verdict"] = verdict
|
||||
return summary
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Daemon mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_daemon(args) -> int:
|
||||
interval = args.interval
|
||||
if interval is None:
|
||||
# default from loop.json schedule.interval_seconds, else 3600
|
||||
project_dir = _find_project_dir(args.project)
|
||||
loop_path = _loop_dir(args.loop, project_dir)
|
||||
cfg = _read_loop_config(loop_path) or {}
|
||||
interval = int(((cfg.get("schedule") or {}).get("interval_seconds")) or 3600)
|
||||
count = 0
|
||||
max_iter = args.max_iterations or 0
|
||||
try:
|
||||
while max_iter == 0 or count < max_iter:
|
||||
cmd_tick(args)
|
||||
count += 1
|
||||
if max_iter == 0 or count < max_iter:
|
||||
time.sleep(interval)
|
||||
except KeyboardInterrupt:
|
||||
project_dir = _find_project_dir(args.project)
|
||||
loop_path = _loop_dir(args.loop, project_dir)
|
||||
if loop_path.exists():
|
||||
_append_tick_log(loop_path, "DAEMON_STOPPED")
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Automaton loop runner")
|
||||
parser.add_argument("--mode", required=True, choices=["tick", "daemon"],
|
||||
help="Loop mode: tick (single tick) or daemon (sleep loop)")
|
||||
parser.add_argument("--loop", required=True, help="Loop name")
|
||||
parser.add_argument("--project", help="Project root directory (defaults to CWD)")
|
||||
parser.add_argument("--interval", type=int, help="Daemon tick interval (seconds)")
|
||||
parser.add_argument("--max-iterations", type=int, default=0,
|
||||
help="Daemon max ticks (0 = unbounded)")
|
||||
parser.add_argument("--json", action="store_true", dest="json_output",
|
||||
help="Print machine-readable tick summary as last line")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.mode == "tick":
|
||||
summary = cmd_tick(args)
|
||||
if args.json_output:
|
||||
print(json.dumps(summary))
|
||||
return 0
|
||||
if args.mode == "daemon":
|
||||
return cmd_daemon(args)
|
||||
print(f"ERROR: unknown mode '{args.mode}'", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+1224
-7
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -60,6 +60,15 @@ echo "Update complete."
|
||||
# Register pre-edit guards for detected harnesses
|
||||
bash "$FRAMEWORK_DIR/scripts/register-guards.sh"
|
||||
|
||||
# Bootstrap self-improvement loop if not present (default-on, D21)
|
||||
if [ ! -d "$FRAMEWORK_DIR/loops/self-improvement" ]; then
|
||||
python3 "$FRAMEWORK_DIR/scripts/status.py" --create-loop self-improvement \
|
||||
--from-template self-improvement --project "$FRAMEWORK_DIR" || true
|
||||
python3 "$FRAMEWORK_DIR/scripts/status.py" --install-schedule self-improvement \
|
||||
--interval 3600 --project "$FRAMEWORK_DIR" || true
|
||||
echo "Created self-improvement loop (default-on). --pause-loop self-improvement to disable."
|
||||
fi
|
||||
|
||||
# Ensure git hooks are installed in current project
|
||||
if git rev-parse --git-dir &>/dev/null 2>&1; then
|
||||
HOOK_DIR="$(git rev-parse --git-dir)/hooks"
|
||||
@@ -67,7 +76,8 @@ if git rev-parse --git-dir &>/dev/null 2>&1; then
|
||||
HOOK_SRC="$HOME/.automaton/scripts/git-hooks/$hook"
|
||||
HOOK_DST="$HOOK_DIR/$hook"
|
||||
if [ -f "$HOOK_SRC" ] && [ ! -f "$HOOK_DST" ]; then
|
||||
ln -sf "$HOOK_SRC" "$HOOK_DST"
|
||||
cp "$HOOK_SRC" "$HOOK_DST"
|
||||
chmod +x "$HOOK_DST"
|
||||
echo "Installed $hook hook"
|
||||
fi
|
||||
done
|
||||
|
||||
+7
-16
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# upgrade.sh — Upgrade existing Automaton projects to support .state files and status.py
|
||||
# upgrade.sh -- Upgrade existing Automaton projects to support .state files and status.py
|
||||
# Usage: ./upgrade.sh [project-path]
|
||||
|
||||
set -euo pipefail
|
||||
@@ -79,24 +79,15 @@ if git -C "$PROJECT_DIR" rev-parse --git-dir &>/dev/null; then
|
||||
HOOK_TARGET="$HOOK_DIR/$HOOK"
|
||||
HOOK_SOURCE="$FRAMEWORK_DIR/scripts/git-hooks/$HOOK"
|
||||
if [ -f "$HOOK_TARGET" ]; then
|
||||
if [ -L "$HOOK_TARGET" ]; then
|
||||
EXISTING_TARGET="$(readlink "$HOOK_TARGET")"
|
||||
if [ "$EXISTING_TARGET" = "$HOOK_SOURCE" ]; then
|
||||
echo "$HOOK hook already linked to automaton."
|
||||
else
|
||||
echo "WARNING: $HOOK hook already exists (symlink to: $EXISTING_TARGET)"
|
||||
echo " To use automaton's hook, run: ln -sf $HOOK_SOURCE $HOOK_TARGET"
|
||||
fi
|
||||
else
|
||||
echo "WARNING: $HOOK hook already exists at $HOOK_TARGET"
|
||||
echo " To replace it with automaton's hook, run: ln -sf $HOOK_SOURCE $HOOK_TARGET"
|
||||
fi
|
||||
echo "WARNING: $HOOK hook already exists at $HOOK_TARGET"
|
||||
echo " To replace it with automaton's hook, run: bash $FRAMEWORK_DIR/scripts/install-hooks.sh"
|
||||
else
|
||||
ln -sf "$HOOK_SOURCE" "$HOOK_TARGET"
|
||||
cp "$HOOK_SOURCE" "$HOOK_TARGET"
|
||||
chmod +x "$HOOK_TARGET"
|
||||
echo "Installed $HOOK hook at $HOOK_TARGET"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "NOTE: Not a git repository. Install the hook manually if needed:"
|
||||
echo " ln -sf $FRAMEWORK_DIR/scripts/git-hooks/pre-commit .git/hooks/pre-commit"
|
||||
fi
|
||||
echo " bash $FRAMEWORK_DIR/scripts/install-hooks.sh"
|
||||
fi
|
||||
|
||||
+78
-16
@@ -78,6 +78,7 @@ MODEL_CONTEXT_WINDOWS: dict[str, int] = {
|
||||
DEFAULT_FALLBACK_CONTEXT_TOKENS = 128_000
|
||||
DEFAULT_HEADROOM_PCT = 25
|
||||
MAX_CONFIG_READ_BYTES = 10 * 1024 # 10KB limit per prompt requirement
|
||||
LOOP_MODE_CONTEXT_FLOOR_KB = 16_000 # D13 hard floor below which --loop-mode refuses
|
||||
|
||||
|
||||
def run_command(cmd: list[str], timeout: float = 5.0) -> Optional[str]:
|
||||
@@ -622,10 +623,24 @@ def recommend_context(
|
||||
overhead_tokens: int,
|
||||
config: dict[str, object],
|
||||
) -> tuple[int, int, int]:
|
||||
"""Return (headroom_pct, recommended_kb, max_peak_kb)."""
|
||||
"""Return (headroom_pct, recommended_kb, max_peak_kb).
|
||||
|
||||
Headroom is applied EXACTLY ONCE. `recommended_kb` is the net budget
|
||||
(after overhead, before headroom). `max_peak_kb` is the per-subtask peak
|
||||
after headroom.
|
||||
|
||||
Previously headroom was applied three times (once while building
|
||||
`recommended_kb` at L642/L644/L648, and again at L654 when deriving
|
||||
`max_peak_kb`). That produced a 25%% headroom acting as a 44%% reduction.
|
||||
Fixed in Tier 1 context-sizing cleanup (D16).
|
||||
|
||||
This function reports honest numbers — a negative or zero budget is
|
||||
returned as-is. Callers that want a non-negative display value should
|
||||
`max(0, ...)` themselves; the detector itself must not lie.
|
||||
"""
|
||||
headroom_pct = int(config.get("headroom_pct", DEFAULT_HEADROOM_PCT))
|
||||
|
||||
# Manual override mode.
|
||||
# Manual override mode — unchanged; already applies headroom exactly once.
|
||||
if not config.get("auto_detect", True):
|
||||
target_kb = int(config.get("target_context_kb", 0))
|
||||
max_peak_kb = int(config.get("max_peak_kb", 0))
|
||||
@@ -634,23 +649,24 @@ def recommend_context(
|
||||
max_peak_kb = target_kb * (100 - headroom_pct) // 100
|
||||
return headroom_pct, target_kb, max_peak_kb
|
||||
|
||||
# Auto-detection mode — build the raw budget WITHOUT applying headroom.
|
||||
# Headroom is applied exactly once at the end.
|
||||
recommended_kb = 0
|
||||
|
||||
if gpu_vram_gb >= 4:
|
||||
# Conservative: 1GB VRAM ≈ 2k context tokens.
|
||||
vram_context_kb = gpu_vram_gb * 2000
|
||||
recommended_kb = vram_context_kb * (100 - headroom_pct) // 100
|
||||
recommended_kb = gpu_vram_gb * 2000
|
||||
elif model_context_kb > 0:
|
||||
recommended_kb = model_context_kb * (100 - headroom_pct) // 100
|
||||
recommended_kb = model_context_kb
|
||||
else:
|
||||
# RAM fallback: 0.75k tokens per GB.
|
||||
ram_context_kb = ram_gb * 750
|
||||
recommended_kb = ram_context_kb * (100 - headroom_pct) // 100
|
||||
recommended_kb = ram_gb * 750
|
||||
|
||||
# Subtract framework overhead.
|
||||
net_kb = max(0, recommended_kb - overhead_tokens)
|
||||
# Subtract framework overhead — WITHOUT the max(0, ...) lie clamp.
|
||||
# A negative budget is an honest signal; downstream code (e.g. --loop-mode)
|
||||
# refuses on it. Callers that need a non-negative display wrap in max(0, ...).
|
||||
net_kb = recommended_kb - overhead_tokens
|
||||
|
||||
# Calculate max peak context based on headroom.
|
||||
# Apply headroom EXACTLY ONCE to derive the per-subtask peak.
|
||||
max_peak_kb = net_kb * (100 - headroom_pct) // 100
|
||||
|
||||
return headroom_pct, net_kb, max_peak_kb
|
||||
@@ -661,6 +677,12 @@ def main() -> int:
|
||||
parser.add_argument("model", nargs="?", help="Model name")
|
||||
parser.add_argument("--model", "-m", dest="model_flag", help="Model name")
|
||||
parser.add_argument("--project", "-p", type=Path, help="Project directory")
|
||||
parser.add_argument(
|
||||
"--loop-mode",
|
||||
action="store_true",
|
||||
help="Strict mode for unattended loops: refuses unknown models and "
|
||||
"available context below the 16k floor (D13). Exits 2 on refuse.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
model_name = args.model_flag or args.model
|
||||
@@ -695,8 +717,45 @@ def main() -> int:
|
||||
gpu_vram_gb, ram_gb, model_context_kb, overhead_tokens, config
|
||||
)
|
||||
|
||||
recommended_k = recommended_kb // 1000 if recommended_kb > 0 else 8
|
||||
max_peak_k = max_peak_kb // 1000 if max_peak_kb > 0 else 6
|
||||
# R2/R4: report HONEST quotients. No `else 8` / `else 6` fallbacks.
|
||||
# A negative or zero budget is the truth; callers can `max(0, ...)` if
|
||||
# they need a non-negative display.
|
||||
recommended_k = recommended_kb // 1000
|
||||
max_peak_k = max_peak_kb // 1000
|
||||
|
||||
# R3: --loop-mode refuses unknown-model and sub-floor available context.
|
||||
# Available context = max_peak_kb (post-overhead, post-headroom, applied once).
|
||||
# User-supplied `Override context window` in config.md is authoritative
|
||||
# per D13; `_parse_config_model` honors it before detect_model_context
|
||||
# returns 0, so an override makes `model_context_kb > 0` always.
|
||||
if args.loop_mode:
|
||||
if model_context_kb == 0:
|
||||
print(
|
||||
"ERROR: model context window is unknown in --loop-mode. "
|
||||
"Set `Override context window` in config.md or pass --model. "
|
||||
"Refusing per D13 (no auto-fallback in unattended mode)."
|
||||
)
|
||||
return 2
|
||||
if max_peak_kb < LOOP_MODE_CONTEXT_FLOOR_KB:
|
||||
print(
|
||||
f"ERROR: available context ({max_peak_k}k) below 16k floor "
|
||||
f"in --loop-mode (D13). Loops must not run against a "
|
||||
f"too-small budget; the framework refuses."
|
||||
)
|
||||
return 2
|
||||
else:
|
||||
# Non-loop callers get a human-readable warning, not an error exit.
|
||||
if recommended_kb <= 0:
|
||||
print(
|
||||
"WARNING: recommended context budget is zero or negative; "
|
||||
"no usable context headroom for the configured system."
|
||||
)
|
||||
if model_context_kb == 0:
|
||||
print(
|
||||
"WARNING: model context window is unknown; budget was derived "
|
||||
"from VRAM/RAM fallback. Set `Override context window` for "
|
||||
"loops (--loop-mode refuses this case)."
|
||||
)
|
||||
|
||||
print(f"Target context: {recommended_k}k tokens")
|
||||
print(f"Headroom: {headroom_pct}%")
|
||||
@@ -708,10 +767,13 @@ def main() -> int:
|
||||
"ram_gb": ram_gb,
|
||||
"model_context_kb": model_context_kb,
|
||||
"framework_overhead_tokens": overhead_tokens,
|
||||
"recommended_kb": recommended_kb,
|
||||
"recommended_k": recommended_k,
|
||||
"recommended_kb": recommended_kb, # net of overhead, before headroom
|
||||
"recommended_k": recommended_k, # honest quotient (may be 0 or negative)
|
||||
"headroom": headroom_pct / 100.0,
|
||||
"max_peak_context_kb": max_peak_kb,
|
||||
"max_peak_context_kb": max_peak_kb, # per-subtask peak after headroom
|
||||
"available_context_kb": max_peak_kb, # alias consumed by loop-runner.py (R4)
|
||||
"loop_mode_eligible": max_peak_kb >= LOOP_MODE_CONTEXT_FLOOR_KB, # boolean: passes D13 floor
|
||||
"loop_mode": bool(args.loop_mode),
|
||||
}
|
||||
print(json.dumps(output, indent=4))
|
||||
return 0
|
||||
|
||||
Reference in New Issue
Block a user