Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/codelicious/chunker.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,13 +418,17 @@ def enforce_token_budget(
that depth a WARNING is logged and the chunk is dispatched anyway —
failing fast at the engine boundary is preferable to dropping work.
"""
import collections

budget = _resolve_token_budget(engines)
out: list[WorkChunk] = []
# Each entry: (chunk, depth, suffix_seed). suffix_seed cycles ``b → c → ...``.
stack: list[tuple[WorkChunk, int, int]] = [(c, 0, 0) for c in chunks]
# ``deque.popleft`` is O(1); list.pop(0) was O(n) and could quadratic on
# 100 chunks.
queue: collections.deque[tuple[WorkChunk, int, int]] = collections.deque((c, 0, 0) for c in chunks)
suffix_alphabet = "bcdefghij"
while stack:
chunk, depth, seed = stack.pop(0)
while queue:
chunk, depth, seed = queue.popleft()
tokens = _estimate_chunk_tokens(chunk, repo)
if tokens <= budget:
out.append(chunk)
Expand All @@ -440,9 +444,11 @@ def enforce_token_budget(
continue
suffix = suffix_alphabet[min(seed, len(suffix_alphabet) - 1)]
head, tail = _split_chunk_in_half(chunk, suffix)
# Push back onto the front so dependent ordering is preserved.
stack.insert(0, (head, depth + 1, seed + 1))
stack.insert(1, (tail, depth + 1, seed + 1))
# Re-process the split halves before any other unsplit chunks so the
# tail of an over-budget chunk is examined before the next original
# chunk's first half — preserves dependency order across recursion.
queue.appendleft((tail, depth + 1, seed + 1))
queue.appendleft((head, depth + 1, seed + 1))
return out


Expand Down
30 changes: 26 additions & 4 deletions src/codelicious/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ def _write_postmortem(
if log_path and log_path.is_file():
try:
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
log_tail = "\n".join(lines[-50:])
# Defuse any backtick fences that would prematurely close our
# rendered code block in the postmortem markdown. Zero-width
# joiner between the backticks renders identically in most
# markdown viewers but no longer matches the closing-fence regex.
log_tail = "\n".join(line.replace("```", "`‍``") for line in lines[-50:])
except OSError:
log_tail = ""

Expand All @@ -104,7 +108,10 @@ def _write_postmortem(
if failed_titles:
body.append("### Failed chunks")
for title in failed_titles[:25]:
body.append(f"- {title}")
# Strip newlines and backticks so a hostile ledger entry can't
# break out of the markdown list or inject code fences.
safe = title.replace("\n", " ").replace("\r", " ").replace("`", "'")
body.append(f"- {safe}")
body.append("")
if log_tail:
body.append("## Log tail (last 50 lines)")
Expand Down Expand Up @@ -197,7 +204,18 @@ def _run_lock(repo_root: Path):
os.write(fd, f"{os.getpid()}\n".encode())
os.fsync(fd)

# Idempotent release: ``main()`` enters the context manager and never
# calls ``__exit__`` until the process is exiting, so atexit handles the
# cleanup on the SystemExit path. The ``finally`` below catches the
# generator-exit / GeneratorExit case. Both paths must be safe to call
# multiple times — otherwise we close already-closed fds (potentially
# closing an unrelated reused fd on a busy process).
released = {"done": False}

def _release() -> None:
if released["done"]:
return
released["done"] = True
try:
fcntl.flock(fd, fcntl.LOCK_UN)
except OSError:
Expand Down Expand Up @@ -1030,8 +1048,12 @@ def main():

# spec v30 Step 1: per-repo advisory lock — second concurrent invocation
# exits 75 (EX_TEMPFAIL) before any git, sandbox, or LLM call happens.
_run_lock_cm = _run_lock(repo_path)
_run_lock_cm.__enter__()
# Use ExitStack so the lock is released on *every* exit path (clean exit,
# uncaught exception, or SystemExit) — and only released once because
# ``_release`` itself is idempotent.
_run_lock_stack = contextlib.ExitStack()
_run_lock_stack.enter_context(_run_lock(repo_path))
atexit.register(_run_lock_stack.close)

_attach_file_log_handler(repo_path)

Expand Down
20 changes: 11 additions & 9 deletions src/codelicious/git/git_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1187,27 +1187,29 @@ def revert_chunk_changes(self) -> bool:
return False

def _branch_exists_locally(self, branch: str) -> bool:
"""Return True iff ``branch`` is a local ref."""
"""Return True iff ``branch`` is a local ref.

``_run_cmd`` returns the stripped stdout string; a non-empty result
means git printed the branch name, an empty result means it did not.
"""
try:
result = self._run_cmd(["git", "branch", "--list", branch], check=False)
except RuntimeError:
stdout = self._run_cmd(["git", "branch", "--list", branch], check=False)
except (RuntimeError, GitOperationError):
return False
# `git branch --list <name>` prints the branch (with optional `*` prefix)
# when present, empty output otherwise.
return bool((getattr(result, "stdout", "") or "").strip())
return bool(stdout)

def _branch_exists_remotely(self, branch: str) -> bool:
"""Return True iff ``branch`` exists on ``origin``. Network failures
treated as "unknown / assume not present" — disambiguation is best-effort."""
try:
result = self._run_cmd(
stdout = self._run_cmd(
["git", "ls-remote", "--heads", "origin", branch],
check=False,
timeout=15,
)
except (RuntimeError, TypeError):
except (RuntimeError, GitOperationError, TypeError):
return False
return bool((getattr(result, "stdout", "") or "").strip())
return bool(stdout)

def _disambiguate_branch(self, candidate: str, *, suffix_hint: str = "") -> str:
"""Resolve branch-name collisions (spec v30 Step 10).
Expand Down
14 changes: 8 additions & 6 deletions src/codelicious/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,18 +391,20 @@ def run(
)

# ── Execute ───────────────────────────────────────
# spec v30 Step 5: try the primary engine, then fail over to
# any remaining engines on a rate-limit signal.
# spec v30 Step 5: try the head of the engine list, fail over
# to the next on a rate-limit. ``self.engine`` always tracks
# ``self._engines[0]`` so verify/fix paths below use the same
# engine that just executed the chunk.
self.engine = self._engines[0]
result = self.engine.execute_chunk(chunk, self.repo_path, context)
while result.message and "Rate limited" in (result.message or "") and len(self._engines) > 1:
while result.message and "Rate limited" in result.message and len(self._engines) > 1:
rate_limited = self._engines.pop(0)
next_engine = self._engines[0]
self.engine = self._engines[0]
logger.warning(
"%s rate-limited; failing over to %s for the remainder of this spec.",
getattr(rate_limited, "name", "engine"),
getattr(next_engine, "name", "engine"),
getattr(self.engine, "name", "engine"),
)
self.engine = next_engine
result = self.engine.execute_chunk(chunk, self.repo_path, context)

# ── Verify ────────────────────────────────────────
Expand Down
24 changes: 22 additions & 2 deletions src/codelicious/tools/audit_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,28 @@ def _cross_process_lock(self):

try:
import fcntl

fcntl.flock(self._lock_fd, fcntl.LOCK_EX)
import time as _time

# Non-blocking with bounded retry: if a peer process holds the lock
# during a slow rotation, we don't want the orchestrator's main
# loop to block indefinitely. After ~30 ms of contention give up
# and proceed with intra-process locking only.
acquired = False
for _ in range(3):
try:
fcntl.flock(self._lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
acquired = True
break
except OSError:
_time.sleep(0.01)
if not acquired:
if not self._cross_process_lock_warned:
console_logger.warning(
"AuditLogger: could not acquire cross-process audit lock; proceeding without it"
)
self._cross_process_lock_warned = True
yield
return
try:
yield
finally:
Expand Down
2 changes: 1 addition & 1 deletion src/codelicious/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -1341,7 +1341,7 @@ def verify_paths(
# ── pytest scoped to mapped tests ────────────────────────────────
try:
proc = _run_with_pgroup_kill(
["python", "-m", "pytest", "-q", "--no-cov", *[str(p) for p in test_paths]],
[sys.executable, "-m", "pytest", "-q", "--no-cov", *[str(p) for p in test_paths]],
cwd=str(repo),
capture_output=True,
text=True,
Expand Down
Loading