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
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ description = "Headless orchestrator that automates the Copilot coding-review-fi
readme = "README.md"
license = {file = "LICENSE"}
requires-python = ">=3.8"
dependencies = [
"rich>=13.0",
]
authors = [
{ name = "Chanakya Valluri" },
]
Expand Down
239 changes: 226 additions & 13 deletions src/autopilot_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
from autopilot_loop.config import load_config
from autopilot_loop.persistence import (
create_task,
get_active_tasks,
get_sessions_dir,
get_task,
get_tasks_on_branch,
list_tasks,
)

Expand Down Expand Up @@ -127,6 +129,15 @@ def cmd_start(args):
else:
branch = config["branch_pattern"].format(task_id=task_id)

# Branch locking: prevent concurrent tasks on the same branch
conflicting = get_tasks_on_branch(branch)
if conflicting:
print("Error: branch %s already has an active task: %s (state: %s)" % (
branch, conflicting[0]["id"], conflicting[0]["state"]), file=sys.stderr)
print("Use 'autopilot stop %s' first, or work on a different branch." % conflicting[0]["id"],
file=sys.stderr)
sys.exit(1)

create_task(
task_id=task_id,
prompt=prompt,
Expand Down Expand Up @@ -250,25 +261,213 @@ def cmd_resume(args):

def cmd_status(args):
"""Show status of all autopilot tasks."""
tasks = list_tasks()
if getattr(args, "json", False):
_status_json()
return

if getattr(args, "watch", False):
_status_watch(interval=getattr(args, "interval", 5))
return

_status_table()


def _format_elapsed(created_at):
elapsed = time.time() - created_at
if elapsed < 60:
return "< 1m"
elif elapsed < 3600:
return "%dm ago" % (elapsed / 60)
else:
return "%.1fh ago" % (elapsed / 3600)


_STATE_STYLES = {
"COMPLETE": "green",
"FAILED": "red",
"STOPPED": "yellow",
"WAIT_REVIEW": "cyan",
"WAIT_CI": "cyan",
"IMPLEMENT": "blue",
"PLAN_AND_IMPLEMENT": "blue",
"FIX": "magenta",
"FIX_CI": "magenta",
}

_STATE_INDICATORS = {
"COMPLETE": "✓",
"FAILED": "✗",
"STOPPED": "■",
}


def _status_table():
"""Print a rich table of all tasks."""
from rich.console import Console
from rich.table import Table

tasks = list_tasks()
if not tasks:
print("No tasks found.")
return

# Header
print("%-10s %-18s %-8s %-11s %s" % ("TASK_ID", "STATE", "PR", "ITERATION", "STARTED"))
print("-" * 65)

for t in tasks:
console = Console()
table = Table(title="autopilot-loop — Sessions", border_style="dim")
table.add_column("#", style="dim", width=3)
table.add_column("Task ID", style="bold")
table.add_column("Mode")
table.add_column("Branch")
table.add_column("State")
table.add_column("PR")
table.add_column("Iter")
table.add_column("Elapsed", justify="right")

for i, t in enumerate(tasks, 1):
state = t["state"]
style = _STATE_STYLES.get(state, "")
indicator = _STATE_INDICATORS.get(state, "●")
state_display = "%s %s" % (indicator, state)
pr = "#%d" % t["pr_number"] if t["pr_number"] else "-"
iteration = "%d/%d" % (t["iteration"], t["max_iterations"])
elapsed = time.time() - t["created_at"]
if elapsed < 3600:
started = "%dm ago" % (elapsed / 60)
else:
started = "%.1fh ago" % (elapsed / 3600)
print("%-10s %-18s %-8s %-11s %s" % (t["id"], t["state"], pr, iteration, started))
mode = t.get("task_mode", "review")
branch = t.get("branch") or "-"
# Truncate long branch names
if len(branch) > 30:
branch = branch[:27] + "..."
elapsed = _format_elapsed(t["created_at"])

table.add_row(
str(i), t["id"], mode, branch,
"[%s]%s[/]" % (style, state_display) if style else state_display,
pr, iteration, elapsed,
)

console.print(table)

# Show active count
active = get_active_tasks()
if active:
console.print("\\n[dim]%d active session(s)[/dim]" % len(active))


def _status_json():
"""Print task status as JSON."""
tasks = list_tasks()
output = []
for t in tasks:
output.append({
"id": t["id"],
"state": t["state"],
"mode": t.get("task_mode", "review"),
"branch": t.get("branch"),
"pr_number": t.get("pr_number"),
"iteration": t["iteration"],
"max_iterations": t["max_iterations"],
"elapsed_seconds": round(time.time() - t["created_at"]),
})
print(json.dumps(output, indent=2))


def _status_watch(interval=5):
"""Auto-refreshing status display."""
from rich.console import Console
from rich.live import Live
from rich.table import Table

console = Console()

def build_table():
tasks = list_tasks()
if not tasks:
table = Table(title="autopilot-loop — No sessions")
return table

table = Table(title="autopilot-loop — Sessions (refreshing every %ds)" % interval,
border_style="dim")
table.add_column("#", style="dim", width=3)
table.add_column("Task ID", style="bold")
table.add_column("Mode")
table.add_column("Branch")
table.add_column("State")
table.add_column("PR")
table.add_column("Iter")
table.add_column("Elapsed", justify="right")

for i, t in enumerate(tasks, 1):
state = t["state"]
style = _STATE_STYLES.get(state, "")
indicator = _STATE_INDICATORS.get(state, "●")
state_display = "%s %s" % (indicator, state)
pr = "#%d" % t["pr_number"] if t["pr_number"] else "-"
iteration = "%d/%d" % (t["iteration"], t["max_iterations"])
mode = t.get("task_mode", "review")
branch = t.get("branch") or "-"
if len(branch) > 30:
branch = branch[:27] + "..."
elapsed = _format_elapsed(t["created_at"])

table.add_row(
str(i), t["id"], mode, branch,
"[%s]%s[/]" % (style, state_display) if style else state_display,
pr, iteration, elapsed,
)
return table

try:
with Live(build_table(), console=console, refresh_per_second=1) as live:
while True:
time.sleep(interval)
live.update(build_table())
except KeyboardInterrupt:
pass


def cmd_attach(args):
"""Attach to a task's tmux session."""
task_id = args.task_id
task = get_task(task_id)
if not task:
print("Error: task %s not found" % task_id, file=sys.stderr)
sys.exit(1)

tmux_session = "autopilot-%s" % task_id
try:
subprocess.run(["tmux", "switch-client", "-t", tmux_session], check=True)
except subprocess.CalledProcessError:
# Not inside tmux — try attach instead
try:
os.execvp("tmux", ["tmux", "attach", "-t", tmux_session])
except FileNotFoundError:
print("Error: tmux not found", file=sys.stderr)
sys.exit(1)


def cmd_next(args):
"""Jump to the next session needing attention (STOPPED, FAILED, or input-waiting)."""
tasks = list_tasks()
# Priority: STOPPED > FAILED > active states needing attention
attention_states = ["STOPPED", "FAILED"]
for state in attention_states:
for t in tasks:
if t["state"] == state:
tmux_session = "autopilot-%s" % t["id"]
print("Switching to task %s (state: %s)" % (t["id"], state))
try:
subprocess.run(["tmux", "switch-client", "-t", tmux_session], check=True)
return
except subprocess.CalledProcessError:
try:
os.execvp("tmux", ["tmux", "attach", "-t", tmux_session])
except FileNotFoundError:
print("Error: tmux not found", file=sys.stderr)
sys.exit(1)

# No sessions needing attention
active = get_active_tasks()
if active:
print("No sessions need attention. %d active session(s) running." % len(active))
else:
print("No active sessions.")


def cmd_logs(args):
Expand Down Expand Up @@ -494,7 +693,10 @@ def main():
p_resume.add_argument("--pr", type=int, required=True, help="PR number to resume")

# status
subparsers.add_parser("status", help="Show task status")
p_status = subparsers.add_parser("status", help="Show task status")
p_status.add_argument("--watch", "-w", action="store_true", help="Auto-refresh status display")
p_status.add_argument("--json", action="store_true", help="Output as JSON")
p_status.add_argument("--interval", type=int, default=5, help="Refresh interval in seconds (with --watch)")

# logs
p_logs = subparsers.add_parser("logs", help="Show task logs")
Expand All @@ -516,6 +718,13 @@ def main():
p_fixci.add_argument("--max-iters", type=int, help="Max fix iterations")
p_fixci.add_argument("--model", type=str, help="Model override")

# attach
p_attach = subparsers.add_parser("attach", help="Attach to a task's tmux session")
p_attach.add_argument("task_id", type=str, help="Task ID to attach to")

# next
subparsers.add_parser("next", help="Jump to next session needing attention")

# _run (internal, called from tmux)
p_run = subparsers.add_parser("_run", help=argparse.SUPPRESS)
p_run.add_argument("--task-id", required=True, help=argparse.SUPPRESS)
Expand All @@ -537,6 +746,10 @@ def main():
cmd_restart(args)
elif args.command == "fix-ci":
cmd_fix_ci(args)
elif args.command == "attach":
cmd_attach(args)
elif args.command == "next":
cmd_next(args)
elif args.command == "_run":
cmd_run(args)
else:
Expand Down
28 changes: 28 additions & 0 deletions src/autopilot_loop/persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"get_task",
"update_task",
"list_tasks",
"get_active_tasks",
"get_tasks_on_branch",
"save_review",
"get_reviews",
"save_agent_run",
Expand Down Expand Up @@ -221,6 +223,32 @@ def list_tasks(limit=20):
conn.close()


def get_active_tasks():
"""Get all tasks not in a terminal state (COMPLETE, FAILED, STOPPED)."""
conn = _get_db()
try:
rows = conn.execute(
"SELECT * FROM tasks WHERE state NOT IN ('COMPLETE', 'FAILED', 'STOPPED') "
"ORDER BY created_at DESC"
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()


def get_tasks_on_branch(branch):
"""Get all non-terminal tasks on a given branch. Used for branch locking."""
conn = _get_db()
try:
rows = conn.execute(
"SELECT * FROM tasks WHERE branch = ? AND state NOT IN ('COMPLETE', 'FAILED', 'STOPPED')",
(branch,)
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()


def save_review(task_id, iteration, body, comments):
"""Save a review (body + inline comments as JSON)."""
now = time.time()
Expand Down
40 changes: 40 additions & 0 deletions tests/test_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,46 @@ def test_existing_branch_defaults_to_zero():
assert task["existing_branch"] == 0


def test_get_active_tasks():
persistence.create_task("t1", "prompt")
persistence.create_task("t2", "prompt")
persistence.create_task("t3", "prompt")
persistence.update_task("t1", state="IMPLEMENT", branch="autopilot/t1")
persistence.update_task("t2", state="COMPLETE", branch="autopilot/t2")
persistence.update_task("t3", state="FIX", branch="autopilot/t3")
active = persistence.get_active_tasks()
ids = [t["id"] for t in active]
assert "t1" in ids
assert "t3" in ids
assert "t2" not in ids


def test_get_active_tasks_excludes_stopped_and_failed():
persistence.create_task("t1", "prompt")
persistence.create_task("t2", "prompt")
persistence.update_task("t1", state="STOPPED")
persistence.update_task("t2", state="FAILED")
active = persistence.get_active_tasks()
assert len(active) == 0


def test_get_tasks_on_branch():
persistence.create_task("t1", "prompt")
persistence.create_task("t2", "prompt")
persistence.update_task("t1", state="IMPLEMENT", branch="autopilot/shared")
persistence.update_task("t2", state="FIX", branch="autopilot/other")
tasks = persistence.get_tasks_on_branch("autopilot/shared")
assert len(tasks) == 1
assert tasks[0]["id"] == "t1"


def test_get_tasks_on_branch_excludes_terminal():
persistence.create_task("t1", "prompt")
persistence.update_task("t1", state="COMPLETE", branch="autopilot/done")
tasks = persistence.get_tasks_on_branch("autopilot/done")
assert len(tasks) == 0


def test_last_review_id_persisted():
persistence.create_task("t1", "prompt")
task = persistence.get_task("t1")
Expand Down
Loading