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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ codeframe/
│ ├── diagnostics.py # Failed task analysis
│ ├── diagnostic_agent.py # AI-powered task diagnosis
│ ├── credentials.py # API key and credential management
│ ├── streaming.py # Real-time output streaming for cf work follow
│ └── ...
├── adapters/
│ └── llm/ # LLM provider adapters
Expand Down Expand Up @@ -265,6 +266,8 @@ cf work start <task-id> --execute --verbose # With detailed output
cf work start <task-id> --execute --dry-run # Preview changes
cf work stop <task-id> # Cancel stale run
cf work resume <task-id> # Resume blocked work
cf work follow <task-id> # Stream real-time output
cf work follow <task-id> --tail 50 # Show last 50 lines then stream

# Batch execution (multiple tasks)
cf work batch run <id1> <id2> ... # Execute multiple tasks
Expand Down
178 changes: 178 additions & 0 deletions codeframe/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2409,6 +2409,184 @@ def work_update_description(
raise typer.Exit(1)


@work_app.command("follow")
def work_follow(
task_id: str = typer.Argument(..., help="Task ID to follow (can be partial)"),
workspace_path: Optional[Path] = typer.Option(
None,
"--workspace",
"-w",
help="Workspace path (defaults to current directory)",
),
tail: Optional[int] = typer.Option(
None,
"--tail",
"-n",
help="Show last N lines of buffered output before streaming",
),
Comment on lines +2421 to +2426

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate --tail to avoid negative/zero surprises.
tail <= 0 leads to counterintuitive slicing (e.g., -1 returns nearly all lines). Consider constraining it to >= 1.

🛠️ Proposed validation
     tail: Optional[int] = typer.Option(
         None,
         "--tail",
         "-n",
+        min=1,
         help="Show last N lines of buffered output before streaming",
     ),
🤖 Prompt for AI Agents
In `@codeframe/cli/app.py` around lines 2421 - 2426, The tail Typer option
currently allows non-positive values which cause counterintuitive slicing;
ensure tail is constrained to >= 1 by either adding a min bound on the Option or
validating after parsing. Specifically, update the tail declaration (symbol:
tail) to include a minimum (e.g., typer.Option(..., min=1)) if supported, or add
a simple check where tail is used (e.g., if tail is not None and tail < 1: raise
typer.BadParameter("--tail must be >= 1")) to reject zero/negative inputs.

timeout: Optional[float] = typer.Option(
None,
"--timeout",
"-t",
help="Maximum seconds to wait for output (for testing)",
),
) -> None:
"""Follow real-time execution output of a running task.

Attaches to an active task execution and streams output as it happens.
Shows buffered output when attaching to already-running executions.

If the task has a completed run (no active run), shows the final output.

Example:
cf work follow abc123 # Follow from current point
cf work follow abc123 --tail 50 # Show last 50 lines then stream
"""
from codeframe.core.workspace import get_workspace
from codeframe.core import tasks as tasks_module, runtime
from codeframe.core.streaming import (
get_latest_lines_with_count,
tail_run_output,
run_output_exists,
)

path = workspace_path or Path.cwd()

try:
workspace = get_workspace(path)

# Find task by partial ID
all_tasks = tasks_module.list_tasks(workspace)
matching = [t for t in all_tasks if t.id.startswith(task_id)]

if not matching:
console.print(f"[red]Error:[/red] No task found matching '{task_id}'")
raise typer.Exit(1)

if len(matching) > 1:
console.print(f"[red]Error:[/red] Multiple tasks match '{task_id}':")
for t in matching[:5]:
console.print(f" {t.id[:8]} - {t.title}")
raise typer.Exit(1)

task = matching[0]

# Get active run for task
active_run = runtime.get_active_run(workspace, task.id)

if not active_run:
# Check for recent completed/failed runs
recent_runs = runtime.list_runs(workspace, task_id=task.id, limit=1)

if recent_runs:
last_run = recent_runs[0]

# Status color
status_color = {
runtime.RunStatus.COMPLETED: "green",
runtime.RunStatus.FAILED: "red",
runtime.RunStatus.BLOCKED: "yellow",
}.get(last_run.status, "white")

console.print(
f"[{status_color}]Run {last_run.status.value}[/{status_color}] "
f"for task: {task.title}"
)

# Show final output if available
if run_output_exists(workspace, last_run.id):
console.print("\n[dim]--- Final output ---[/dim]")
lines, total = get_latest_lines_with_count(
workspace, last_run.id, count=tail or 50
)
if tail and total > tail:
console.print(f"[dim](showing last {tail} of {total} lines)[/dim]")
for line in lines:
console.print(line.rstrip())
else:
console.print("[dim]No output captured for this run.[/dim]")

raise typer.Exit(0)
else:
console.print(f"[yellow]No active run found for task:[/yellow] {task.title}")
console.print("[dim]Start a run with:[/dim]")
console.print(f" cf work start {task.id[:8]} --execute")
raise typer.Exit(1)

# We have an active run - stream it
console.print(f"[blue]Following task:[/blue] {task.title}")
console.print(f"[dim]Run: {active_run.id[:8]} | Status: {active_run.status.value}[/dim]")

# Show buffered output if requested
start_line = 0
if tail:
lines, total = get_latest_lines_with_count(
workspace, active_run.id, count=tail
)
if lines:
console.print(f"\n[dim]--- Buffered output (last {len(lines)} of {total} lines) ---[/dim]")
for line in lines:
console.print(f"[dim]{line.rstrip()}[/dim]")
console.print("[dim]--- Live output ---[/dim]\n")
start_line = total # Skip already-shown lines

# Calculate max_wait for testing
max_wait = timeout if timeout else None

# Terminal run statuses
TERMINAL_STATUSES = {
runtime.RunStatus.COMPLETED,
runtime.RunStatus.FAILED,
runtime.RunStatus.BLOCKED,
}

try:
import time

last_status_check = time.time()
STATUS_CHECK_INTERVAL = 1.0 # Check status every 1 second

# Stream output
for line in tail_run_output(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

The terminal status check at line 2555 only runs after tail_run_output yields a new line. If the run completes without producing new output, the loop hangs indefinitely. Consider checking run status inside the tail_run_output generator or implementing a separate status-polling mechanism that runs independently of line yields.

🚀 Want me to fix this? Reply ex: "fix it for me".

workspace,
active_run.id,
since_line=start_line,
poll_interval=0.3,
max_wait=max_wait,
):
console.print(line.rstrip())

# Check run status periodically (not on every line)
current_time = time.time()
if current_time - last_status_check >= STATUS_CHECK_INTERVAL:
last_status_check = current_time
current_run = runtime.get_run(workspace, active_run.id)
if current_run and current_run.status in TERMINAL_STATUSES:
# Show completion message
status_color = {
runtime.RunStatus.COMPLETED: "green",
runtime.RunStatus.FAILED: "red",
runtime.RunStatus.BLOCKED: "yellow",
}.get(current_run.status, "white")

console.print(
f"\n[{status_color}]Run {current_run.status.value}[/{status_color}]"
)
break
Comment on lines +2549 to +2575

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Follow can hang when the run finishes without emitting new output.
Status polling only happens when a line is yielded. If the run completes after the last line (or never logs), the loop never checks terminal status and can block indefinitely.

🛠️ Suggested refactor to decouple status checks from output
-            last_status_check = time.time()
-            STATUS_CHECK_INTERVAL = 1.0  # Check status every 1 second
-
-            # Stream output
-            for line in tail_run_output(
-                workspace,
-                active_run.id,
-                since_line=start_line,
-                poll_interval=0.3,
-                max_wait=max_wait,
-            ):
-                console.print(line.rstrip())
-
-                # Check run status periodically (not on every line)
-                current_time = time.time()
-                if current_time - last_status_check >= STATUS_CHECK_INTERVAL:
-                    last_status_check = current_time
-                    current_run = runtime.get_run(workspace, active_run.id)
-                    if current_run and current_run.status in TERMINAL_STATUSES:
-                        # Show completion message
-                        status_color = {
-                            runtime.RunStatus.COMPLETED: "green",
-                            runtime.RunStatus.FAILED: "red",
-                            runtime.RunStatus.BLOCKED: "yellow",
-                        }.get(current_run.status, "white")
-
-                        console.print(
-                            f"\n[{status_color}]Run {current_run.status.value}[/{status_color}]"
-                        )
-                        break
+            STATUS_CHECK_INTERVAL = 1.0  # Check status every 1 second
+            current_line = start_line
+            overall_start = time.time()
+
+            while True:
+                for line in tail_run_output(
+                    workspace,
+                    active_run.id,
+                    since_line=current_line,
+                    poll_interval=0.3,
+                    max_wait=STATUS_CHECK_INTERVAL,
+                ):
+                    console.print(line.rstrip())
+                    current_line += 1
+
+                # Check run status even if no new output arrived
+                current_run = runtime.get_run(workspace, active_run.id)
+                if current_run and current_run.status in TERMINAL_STATUSES:
+                    status_color = {
+                        runtime.RunStatus.COMPLETED: "green",
+                        runtime.RunStatus.FAILED: "red",
+                        runtime.RunStatus.BLOCKED: "yellow",
+                    }.get(current_run.status, "white")
+                    console.print(f"\n[{status_color}]Run {current_run.status.value}[/{status_color}]")
+                    break
+
+                if max_wait is not None and (time.time() - overall_start) >= max_wait:
+                    break
🤖 Prompt for AI Agents
In `@codeframe/cli/app.py` around lines 2549 - 2575, The loop currently only polls
run status when tail_run_output yields a line, which can hang if the run
finishes without emitting new output; fix by decoupling output consumption from
status polling: get an iterator from tail_run_output(...) (use the same args),
then run a while loop that attempts to read the next line non-blockingly (or
with a short timeout) and prints it when available, but independently every
STATUS_CHECK_INTERVAL call runtime.get_run(workspace, active_run.id) and compare
against TERMINAL_STATUSES; update last_status_check when you poll, break and
print the colored completion message when the run is terminal, and ensure
StopIteration or generator exhaustion also triggers a final status check to
avoid hanging.


except KeyboardInterrupt:
console.print("\n[yellow]Streaming interrupted[/yellow]")
console.print(f"[dim]Run is still active. Resume with: cf work follow {task.id[:8]}[/dim]")
raise typer.Exit(0)

except FileNotFoundError:
console.print(f"[red]Error:[/red] No workspace found at {path}")
raise typer.Exit(1)
except ValueError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)


# =============================================================================
# Batch execution commands (subcommand group: cf work batch <cmd>)
# =============================================================================
Expand Down
18 changes: 17 additions & 1 deletion codeframe/core/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

if TYPE_CHECKING:
from codeframe.core.conductor import GlobalFixCoordinator
from codeframe.core.streaming import RunOutputLogger

# Safe shell commands that can be executed without full shell interpretation
SAFE_SHELL_COMMANDS = frozenset({
Expand Down Expand Up @@ -397,6 +398,7 @@ def __init__(
debug: bool = False,
verbose: bool = False,
fix_coordinator: Optional["GlobalFixCoordinator"] = None,
output_logger: Optional["RunOutputLogger"] = None,
):
"""Initialize the agent.

Expand All @@ -409,6 +411,7 @@ def __init__(
debug: If True, write detailed debug log to workspace
verbose: If True, print detailed progress to stdout
fix_coordinator: Optional coordinator for global fixes (for parallel execution)
output_logger: Optional logger for streaming output to file (for cf work follow)
"""
self.workspace = workspace
self.llm = llm_provider
Expand All @@ -418,6 +421,7 @@ def __init__(
self.debug = debug
self.verbose = verbose
self.fix_coordinator = fix_coordinator
self.output_logger = output_logger

self.state = AgentState()
self.context: Optional[TaskContext] = None
Expand All @@ -433,10 +437,22 @@ def __init__(
self._setup_debug_log()

def _verbose_print(self, message: str) -> None:
"""Print message only if verbose mode is enabled."""
"""Print message to stdout (if verbose) and to output log file.

The output log file is always written to (if logger provided) to enable
streaming via `cf work follow`, even when verbose=False.

Args:
message: Message to print/log
"""
# Print to stdout if verbose mode is enabled
if self.verbose:
print(message)

# Always write to output log if logger is provided (for cf work follow)
if self.output_logger:
self.output_logger.write(message + "\n")

def run(self, task_id: str) -> AgentState:
"""Run the agent on a task.

Expand Down
Loading
Loading