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
4 changes: 2 additions & 2 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
<!--- Required: describe the main purpose of this code change. --->

<!--- ## Details
Optional: anything specific reviewers should be aware of.
Optional: anything specific reviewers should be aware of.
--->

<!--
Check off each item with [x] before creating the PR.
-->
- [ ] run `pre-commit` to ensure code style
- [ ] run `pre-commit run --all-files` to ensure code style
- [ ] unit tests added or updated (if applicable)
27 changes: 27 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
default_stages: [pre-commit, pre-push, manual]
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-ast
- id: check-added-large-files
- id: check-merge-conflict
- id: debug-statements
- id: detect-private-key

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.1
hooks:
- id: ruff
args: ["--fix", "--exit-non-zero-on-fix"]
- id: ruff-format

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0
hooks:
- id: mypy
args: ["--ignore-missing-imports", "src/"]
pass_filenames: false
86 changes: 67 additions & 19 deletions src/TA_main2main_workflow/agent/opencode_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@

# ── backend detection ────────────────────────────────────────────────────────


def _detect_backend() -> str:
"""Detect which AI backend to use. Checks AI_BACKEND env var first,
then falls back to whatever is available on PATH."""
Expand Down Expand Up @@ -72,15 +73,18 @@ def _detect_backend() -> str:

# ── prompt loader ────────────────────────────────────────────────────────────


def _build_prompt(inputs: dict[str, Any]) -> str:
from collections import defaultdict

template = _PROMPT_PATH.read_text(encoding="utf-8")
ctx = defaultdict(str, {k: str(v) for k, v in inputs.items()})
return template.format_map(ctx)


# ── result model ─────────────────────────────────────────────────────────────


class AIResult(BaseModel):
modified_files: list[str] = Field(default_factory=list)
is_noop: bool = Field(default=False)
Expand All @@ -92,6 +96,7 @@ class AIResult(BaseModel):

# ── main entry point ─────────────────────────────────────────────────────────


def run_opencode_adapter(inputs: dict[str, Any]) -> AIResult:
"""Run the AI adapter for conflict resolution or test fixing.

Expand Down Expand Up @@ -125,9 +130,12 @@ def run_opencode_adapter(inputs: dict[str, Any]) -> AIResult:
if result.modified_files:
print(f" Modified: {', '.join(result.modified_files)}", flush=True)
if result.resolved_conflicts:
print(f" Resolved conflicts: {', '.join(result.resolved_conflicts)}", flush=True)
print(
f" Resolved conflicts: {', '.join(result.resolved_conflicts)}",
flush=True,
)
if result.is_noop:
print(f" (no changes needed)", flush=True)
print(" (no changes needed)", flush=True)

return result

Expand All @@ -136,6 +144,7 @@ def run_opencode_adapter(inputs: dict[str, Any]) -> AIResult:
# opencode backend (JSONL streaming)
# ═══════════════════════════════════════════════════════════════════════════════


def _run_opencode(inputs: dict[str, Any]) -> AIResult:
"""opencode backend: JSONL streaming with stale-timeout retry."""
base_prompt = _build_prompt(inputs)
Expand Down Expand Up @@ -170,12 +179,17 @@ def _run_opencode(inputs: dict[str, Any]) -> AIResult:

if reason == "stale_timeout" and attempt < _MAX_STALE_RETRIES:
retry = attempt + 1
print(f"\n[opencode] retrying after stale timeout ({retry}/{_MAX_STALE_RETRIES})", flush=True)
print(
f"\n[opencode] retrying after stale timeout ({retry}/{_MAX_STALE_RETRIES})",
flush=True,
)
prompt = _build_opencode_continue(base_prompt, inputs, retry)
continue

if stderr_path and stderr_path.exists():
stderr_content = stderr_path.read_text(encoding="utf-8", errors="replace")[-2000:]
stderr_content = stderr_path.read_text(encoding="utf-8", errors="replace")[
-2000:
]
if stderr_content:
print(f"\n[opencode] stderr tail:\n{stderr_content}", flush=True)
break
Expand All @@ -186,8 +200,10 @@ def _run_opencode(inputs: dict[str, Any]) -> AIResult:
return result


def _build_opencode_continue(base_prompt: str, inputs: dict[str, Any], retry: int) -> str:
return f"""Continue the task for step {inputs.get('step_id', '')}.
def _build_opencode_continue(
base_prompt: str, inputs: dict[str, Any], retry: int
) -> str:
return f"""Continue the task for step {inputs.get("step_id", "")}.

The previous opencode run produced no output for {_STALE_SECONDS} seconds and
was terminated. This is continuation retry {retry}/{_MAX_STALE_RETRIES}.
Expand All @@ -212,7 +228,9 @@ def _print_prompt(prompt: str, attempt: int) -> None:
print(f"{'━' * 60}", flush=True)
if len(prompt) > 8000:
print(prompt[:4000])
print(f"\n... [{len(prompt) - 8000} chars truncated, see log for full prompt] ...\n")
print(
f"\n... [{len(prompt) - 8000} chars truncated, see log for full prompt] ...\n"
)
print(prompt[-4000:])
else:
print(prompt)
Expand Down Expand Up @@ -249,8 +267,10 @@ def _run_opencode_once(
stderr_fh = stderr_path.open("a", encoding="utf-8") if stderr_path else None
proc = subprocess.Popen(
[
"opencode", "run",
"--format", "json",
"opencode",
"run",
"--format",
"json",
"--dangerously-skip-permissions",
prompt,
],
Expand Down Expand Up @@ -287,12 +307,18 @@ def _stdout_reader():
except queue.Empty:
now = time.monotonic()
if now > deadline:
print(f"\n[opencode] TOTAL TIMEOUT ({_TIMEOUT_MINUTES}min), killing process", flush=True)
print(
f"\n[opencode] TOTAL TIMEOUT ({_TIMEOUT_MINUTES}min), killing process",
flush=True,
)
proc.kill()
stop_reason = "total_timeout"
break
if now - last_output_time > _STALE_SECONDS:
print(f"\n[opencode] STALE TIMEOUT ({_STALE_SECONDS}s no output), killing process", flush=True)
print(
f"\n[opencode] STALE TIMEOUT ({_STALE_SECONDS}s no output), killing process",
flush=True,
)
proc.kill()
stop_reason = "stale_timeout"
break
Expand Down Expand Up @@ -363,8 +389,15 @@ def _print_opencode_event(line: str, state: _EventState) -> None:
elif status == "completed":
output = st.get("output", "")
if output:
display = output if len(output) <= 2000 else output[:2000] + "\n... [truncated]"
print(f"\n {'─' * 56}\n [AI output]\n {display}\n {'─' * 56}", flush=True)
display = (
output
if len(output) <= 2000
else output[:2000] + "\n... [truncated]"
)
print(
f"\n {'─' * 56}\n [AI output]\n {display}\n {'─' * 56}",
flush=True,
)


def _log_opencode_event(line: str, state: _EventState, fh: Any) -> None:
Expand Down Expand Up @@ -402,6 +435,7 @@ def _log_opencode_event(line: str, state: _EventState, fh: Any) -> None:
# in the meantime), this implementation streams stdout line-by-line in real
# time. A heartbeat "." is printed every 15s of silence to show liveness.


def _run_claude(inputs: dict[str, Any]) -> AIResult:
"""Run Claude Code with real-time streaming output.

Expand Down Expand Up @@ -431,8 +465,11 @@ def _run_claude(inputs: dict[str, Any]) -> AIResult:
if log_path:
_log_prompt(prompt, 0, log_path)

print(f"\n > [claude] Starting Claude Code (timeout={_TIMEOUT_MINUTES}min)...", flush=True)
print(f" (streaming output in real time — '.' = still thinking)", flush=True)
print(
f"\n > [claude] Starting Claude Code (timeout={_TIMEOUT_MINUTES}min)...",
flush=True,
)
print(" (streaming output in real time — '.' = still thinking)", flush=True)

# ── Launch claude ──────────────────────────────────────────────────────
stderr_fh = stderr_path.open("a", encoding="utf-8") if stderr_path else None
Expand Down Expand Up @@ -475,12 +512,18 @@ def _write_stdin():
if line is None:
now = time.monotonic()
if now > deadline:
print(f"\n [claude] TOTAL TIMEOUT ({_TIMEOUT_MINUTES}min), killing process", flush=True)
print(
f"\n [claude] TOTAL TIMEOUT ({_TIMEOUT_MINUTES}min), killing process",
flush=True,
)
proc.kill()
stop_reason = "total_timeout"
break
if now - last_output_time > _STALE_SECONDS:
print(f"\n [claude] STALE TIMEOUT ({_STALE_SECONDS}s no output), killing process", flush=True)
print(
f"\n [claude] STALE TIMEOUT ({_STALE_SECONDS}s no output), killing process",
flush=True,
)
proc.kill()
stop_reason = "stale_timeout"
break
Expand Down Expand Up @@ -520,7 +563,9 @@ def _write_stdin():
if proc.returncode != 0:
print(f"\n [claude] exited with code {proc.returncode}", flush=True)
if stderr_path and stderr_path.exists():
stderr_tail = stderr_path.read_text(encoding="utf-8", errors="replace")[-2000:]
stderr_tail = stderr_path.read_text(encoding="utf-8", errors="replace")[
-2000:
]
if stderr_tail:
print(f" [claude] stderr tail:\n{stderr_tail}", flush=True)
else:
Expand Down Expand Up @@ -556,7 +601,10 @@ def _read_line_with_timeout(stream: Any, timeout: float) -> str | None:
# shared result builder
# ═══════════════════════════════════════════════════════════════════════════════

def _build_result(step_dir: Path | None, ascend_path: str, output_text: str) -> AIResult:

def _build_result(
step_dir: Path | None, ascend_path: str, output_text: str
) -> AIResult:
"""Build AIResult from AI output: extract summary, detect modified files."""
summary = ""
if step_dir:
Expand Down
55 changes: 30 additions & 25 deletions src/TA_main2main_workflow/flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@
from TA_main2main_workflow.utils import (
UpgradeCompleted,
UpgradeFailed,
HasNewCommits,
HasNoNewCommits,
WORKSPACE_DIR,
)
from TA_main2main_workflow.pipeline.prepare import prepare
Expand All @@ -36,7 +34,10 @@
from TA_main2main_workflow.pipeline.test import test_and_fix_loop
from TA_main2main_workflow.pipeline.commit import commit_step
from TA_main2main_workflow.pipeline.finalize import finalize
from TA_main2main_workflow.pipeline.ir_patch import build_baseline_llvm, per_step_ir_patch
from TA_main2main_workflow.pipeline.ir_patch import (
build_baseline_llvm,
per_step_ir_patch,
)

log = get_logger(__name__)

Expand Down Expand Up @@ -97,9 +98,7 @@ def run(self) -> str:
step_id = step["id"]
ctx = ctx.copy_with(retry_count=0)

log.header(
f"Step {ctx.current_step + 1}/{ctx.total_steps}: {step_id}"
)
log.header(f"Step {ctx.current_step + 1}/{ctx.total_steps}: {step_id}")
log.key_value("commits in step", str(step["commit_count"]))
log.key_value("end commit", step["end_commit"][:12])
reason = step.get("reason", "line_budget")
Expand Down Expand Up @@ -129,13 +128,15 @@ def run(self) -> str:
log.status(True, "Conflicts resolved")

# ── Step C: Build/Test — IR patch or standard ───────
llvm_hash_changed = (reason == "llvm_version")
llvm_hash_changed = reason == "llvm_version"
if not llvm_hash_changed:
llvm_hash_changed = llvm_hash_changed_after_merge(ctx)
if llvm_hash_changed:
if reason != "llvm_version":
log.info(f"[{step_id}] LLVM hash changed during merge "
f"(post-merge detection) — routing to IR patch pipeline")
log.info(
f"[{step_id}] LLVM hash changed during merge "
f"(post-merge detection) — routing to IR patch pipeline"
)
log.section(f"LLVM Version Change in {step_id} — IR Patch Pipeline")
with timed("ir-patch"):
ctx = per_step_ir_patch(ctx, self.config, step)
Expand Down Expand Up @@ -176,25 +177,26 @@ def run(self) -> str:
ctx.step_pr_descriptions.append(desc)

# Record per-step detail for sync report
ctx.step_details.append({
"step_id": step_id,
"step_index": ctx.current_step + 1,
"commits": step["commit_count"],
"end_commit": step["end_commit"][:12],
"source_lines": step.get("source_changed_lines", 0),
"conflict_files": len(ctx.conflict_files),
"build_fixes": ctx.build_fix_count,
"test_fixes": ctx.test_fix_count,
"retries": ctx.retry_count,
"reason": reason,
})
ctx.step_details.append(
{
"step_id": step_id,
"step_index": ctx.current_step + 1,
"commits": step["commit_count"],
"end_commit": step["end_commit"][:12],
"source_lines": step.get("source_changed_lines", 0),
"conflict_files": len(ctx.conflict_files),
"build_fixes": ctx.build_fix_count,
"test_fixes": ctx.test_fix_count,
"retries": ctx.retry_count,
"reason": reason,
}
)

# Advance to next step
ctx = ctx.copy_with(current_step=ctx.current_step + 1)
log.status(
True,
f"Step {step_id} completed "
f"({ctx.current_step}/{ctx.total_steps})",
f"Step {step_id} completed ({ctx.current_step}/{ctx.total_steps})",
)

# ── Phase 4: Finalize ───────────────────────────────────────
Expand All @@ -207,8 +209,11 @@ def run(self) -> str:
self._push_pr(ctx)

ctx.summary_rows.append(
("Single-Step Sync", "PASS",
f"{ctx.total_steps} step(s), branch: {ctx.work_branch}")
(
"Single-Step Sync",
"PASS",
f"{ctx.total_steps} step(s), branch: {ctx.work_branch}",
)
)
log.table(ctx.summary_rows)
log.elapsed(total_elapsed())
Expand Down
Loading