feat: add create mode for new files

Auto-detects create vs edit based on whether target_path exists.
Edit jobs use unified diff/patch (existing behaviour).
Create jobs ask the model to output the complete new file, validated
by file type (_validate_output handles .py compile, .html tag check,
generic non-empty check).

- _resolve_target: handles /opt/sites/ and /opt/stacks/ paths
- _CREATOR_SYSTEM + _build_create_prompt + _ask_creator: full-file output
- _validate_output: replaces direct _syntax_check calls in the loop
- run_coding_loop: branches on mode, stores mode on job dict
- approve: mkdir parents for new paths, skip backup if no original
- submit_task: initialise mode field as None

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 12:39:24 +00:00
parent 7fa1fb9e99
commit 0c3e88ac2f
+88 -20
View File
@@ -28,6 +28,12 @@ JOBS_FILE = STACKS_DIR / "qyburn-coder" / "jobs.json"
MAX_ATTEMPTS = 3 MAX_ATTEMPTS = 3
def _resolve_target(target_path: str) -> Path:
if target_path.startswith("/opt/"):
return Path(target_path)
return STACKS_DIR / target_path
def _save_jobs(): def _save_jobs():
try: try:
JOBS_FILE.write_text(json.dumps(jobs, indent=2)) JOBS_FILE.write_text(json.dumps(jobs, indent=2))
@@ -110,12 +116,43 @@ def _syntax_check(code: str, filename: str) -> str | None:
Path(tmp).unlink(missing_ok=True) Path(tmp).unlink(missing_ok=True)
def _validate_output(content: str, filename: str) -> str | None:
"""Returns error string or None if valid. Handles .py, .html, and generic files."""
if not content.strip():
return "model returned empty output"
ext = Path(filename).suffix.lower()
if ext == ".py":
return _syntax_check(content, filename)
if ext in (".html", ".htm"):
low = content.lower()
if "<html" not in low and "<!doctype" not in low:
return "output does not appear to be valid HTML (missing <html> or <!DOCTYPE>)"
return None
return None
def _make_diff(original: str, modified: str, filename: str) -> str: def _make_diff(original: str, modified: str, filename: str) -> str:
a = original.splitlines(keepends=True) a = original.splitlines(keepends=True)
b = modified.splitlines(keepends=True) b = modified.splitlines(keepends=True)
return "".join(difflib.unified_diff(a, b, fromfile=f"a/{filename}", tofile=f"b/{filename}")) or "(no changes)" return "".join(difflib.unified_diff(a, b, fromfile=f"a/{filename}", tofile=f"b/{filename}")) or "(no changes)"
def _build_create_prompt(job: dict, attempt: int, last_error: str | None) -> str:
lines = [f"Task: {job['description']}"]
if job.get("constraints"):
lines.append(f"Constraints: {job['constraints']}")
if attempt > 1 and last_error:
lines.append(f"\nPrevious attempt failed:\n{last_error}")
lines.append("Fix the issue in the output.")
lines += [
"",
f"Create the file: {job['target_path']}",
"",
"Output the complete file content:",
]
return "\n".join(lines)
def _build_prompt(job: dict, original: str, attempt: int, last_error: str | None) -> str: def _build_prompt(job: dict, original: str, attempt: int, last_error: str | None) -> str:
lines = [f"Task: {job['description']}"] lines = [f"Task: {job['description']}"]
if job.get("constraints"): if job.get("constraints"):
@@ -167,6 +204,28 @@ _CODER_SYSTEM = (
"+ added lines, - removed lines." "+ added lines, - removed lines."
) )
_CREATOR_SYSTEM = (
"You are a code and file generator. "
"When given a task, output ONLY the complete new file as raw text. "
"No markdown fences, no explanations, no preamble. "
"Output only the file content itself, starting with the very first character of the file."
)
async def _ask_creator(task_prompt: str) -> str:
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(f"{OLLAMA_URL}/api/chat", json={
"model": CODER_MODEL,
"messages": [
{"role": "system", "content": _CREATOR_SYSTEM},
{"role": "user", "content": task_prompt},
],
"stream": False,
"options": {"temperature": 0.1, "num_predict": 8192},
})
r.raise_for_status()
return r.json()["message"]["content"]
async def _ask_coder(task_prompt: str) -> str: async def _ask_coder(task_prompt: str) -> str:
async with httpx.AsyncClient(timeout=300.0) as client: async with httpx.AsyncClient(timeout=300.0) as client:
@@ -227,17 +286,18 @@ async def run_coding_loop(job_id: str):
job["status"] = "running" job["status"] = "running"
job["updated_at"] = datetime.now(timezone.utc).isoformat() job["updated_at"] = datetime.now(timezone.utc).isoformat()
target = STACKS_DIR / job["target_path"] target = _resolve_target(job["target_path"])
if not target.exists(): mode = "edit" if target.exists() else "create"
job["status"] = "failed" job["mode"] = mode
job["error"] = f"target file not found: {job['target_path']}"
job["updated_at"] = datetime.now(timezone.utc).isoformat() job["updated_at"] = datetime.now(timezone.utc).isoformat()
logger.error(f"[{job_id[:8]}] {job['error']}")
return if mode == "edit":
original = target.read_text()
else:
original = ""
logger.info(f"[{job_id[:8]}] target does not exist — create mode")
await _warm_coder_model() await _warm_coder_model()
original = target.read_text()
sandbox_dir = SANDBOX_DIR / job_id sandbox_dir = SANDBOX_DIR / job_id
sandbox_dir.mkdir(parents=True, exist_ok=True) sandbox_dir.mkdir(parents=True, exist_ok=True)
sandbox_file = sandbox_dir / Path(job["target_path"]).name sandbox_file = sandbox_dir / Path(job["target_path"]).name
@@ -258,37 +318,43 @@ async def run_coding_loop(job_id: str):
logger.info(f"[{job_id[:8]}] attempt {attempt}/{MAX_ATTEMPTS}") logger.info(f"[{job_id[:8]}] attempt {attempt}/{MAX_ATTEMPTS}")
try: try:
if mode == "edit":
response = await _ask_coder(_build_prompt(job, original, attempt, last_error)) response = await _ask_coder(_build_prompt(job, original, attempt, last_error))
else:
response = await _ask_creator(_build_create_prompt(job, attempt, last_error))
except Exception as e: except Exception as e:
last_error = f"Ollama error: {e}" last_error = f"Ollama error: {e}"
logger.warning(f"[{job_id[:8]}] attempt {attempt} — LLM error: {e}") logger.warning(f"[{job_id[:8]}] attempt {attempt} — LLM error: {e}")
continue continue
job["tokens_estimated"] = job.get("tokens_estimated", 0) + len(response) // 4 job["tokens_estimated"] = job.get("tokens_estimated", 0) + len(response) // 4
diff_text = _extract_diff(response) raw = _extract_diff(response)
last_diff = diff_text last_diff = raw
patched, patch_error = _apply_diff(original, diff_text) if mode == "edit":
content, patch_error = _apply_diff(original, raw)
if patch_error: if patch_error:
last_error = patch_error last_error = patch_error
logger.warning(f"[{job_id[:8]}] attempt {attempt} patch error: {patch_error[:120]}") logger.warning(f"[{job_id[:8]}] attempt {attempt} patch error: {patch_error[:120]}")
continue continue
else:
content = raw
syntax_error = _syntax_check(patched, Path(job["target_path"]).name) error = _validate_output(content, Path(job["target_path"]).name)
if syntax_error is None: if error is None:
sandbox_file.write_text(patched) sandbox_file.write_text(content)
job["status"] = "pending_review" job["status"] = "pending_review"
job["diff"] = diff_text job["diff"] = raw if mode == "edit" else f"[NEW FILE — {Path(job['target_path']).name}]\n\n{content}"
job["sandbox_file"] = str(sandbox_file) job["sandbox_file"] = str(sandbox_file)
job["original"] = original job["original"] = original
job["error"] = None job["error"] = None
job["updated_at"] = datetime.now(timezone.utc).isoformat() job["updated_at"] = datetime.now(timezone.utc).isoformat()
logger.info(f"[{job_id[:8]}] patch OK on attempt {attempt} — pending review") logger.info(f"[{job_id[:8]}] {mode} OK on attempt {attempt} — pending review")
_write_last_run() _write_last_run()
return return
last_error = syntax_error last_error = error
logger.warning(f"[{job_id[:8]}] attempt {attempt} syntax error: {syntax_error[:120]}") logger.warning(f"[{job_id[:8]}] attempt {attempt} error: {error[:120]}")
_write_handoff(job, original, last_diff) _write_handoff(job, original, last_diff)
job["status"] = "escalated" job["status"] = "escalated"
@@ -463,7 +529,7 @@ async def submit_task(req: TaskRequest, background_tasks: BackgroundTasks):
"constraints": req.constraints, "attempts": 0, "constraints": req.constraints, "attempts": 0,
"created_at": now, "updated_at": now, "created_at": now, "updated_at": now,
"cancelled": False, "error": None, "diff": None, "cancelled": False, "error": None, "diff": None,
"sandbox_file": None, "original": None, "mode": None, "sandbox_file": None, "original": None,
"tokens_estimated": 0, "tokens_estimated": 0,
} }
background_tasks.add_task(run_coding_loop, job_id) background_tasks.add_task(run_coding_loop, job_id)
@@ -521,7 +587,9 @@ async def approve_job(job_id: str, request: Request):
sandbox_file = Path(job["sandbox_file"]) sandbox_file = Path(job["sandbox_file"])
if not sandbox_file.exists(): if not sandbox_file.exists():
raise HTTPException(status_code=500, detail="sandbox file missing") raise HTTPException(status_code=500, detail="sandbox file missing")
target = STACKS_DIR / job["target_path"] target = _resolve_target(job["target_path"])
target.parent.mkdir(parents=True, exist_ok=True)
if job.get("original"):
backup = target.with_suffix(target.suffix + ".qyburn-bak") backup = target.with_suffix(target.suffix + ".qyburn-bak")
backup.write_text(job["original"]) backup.write_text(job["original"])
target.write_text(sandbox_file.read_text()) target.write_text(sandbox_file.read_text())