Planning overhaul - #155
Conversation
e2cef64 to
c7205c2
Compare
c7205c2 to
dd05b0c
Compare
Support local/synced plan execution by rendering plan_dir and @plan_files in the implement template while preserving issue-body fallback. Add prompt tests and update plan/progress tracking for the completed Phase 4 criterion.
Create draft PRs immediately after plan-file commits so PR context exists throughout implementation. This also keeps the implement/review pipeline focused on pushing updates and review only.
Run choose from the worktree for plan-backed iterations and stop implement cycles when the agent reports NOTHING_LEFT, with a safety cap to prevent runaway loops. Update integration runners/assertions and plan tracking docs to reflect the new inner-loop behavior.
|
Four warning-level findings require attention before merge: missing untrusted-content wrapping in plan-mode prompts weakens prompt-injection defenses, copy_dir_recursive follows symlinks enabling potential file exfiltration, zero-cycle plan runs incorrectly mark PRs as ready, and Linear ID stripping breaks URL rewriting.
Category Breakdown
|
hsyntax
left a comment
There was a problem hiding this comment.
Review: 13 finding(s) across the changes.
| - (#{{issue_number}}) — {{issue_url}} | ||
| - Branch `{{branch_name}}` · Worktree `{{worktree_path}}` · Repo `{{repo_path}}` | ||
|
|
||
| {% if plan_files %} |
There was a problem hiding this comment.
WARNING (prompt-injection) plan-mode-missing-untrusted-content-tags: Remote-synced plan files contain attacker-controlled content (GitHub/Linear issue bodies). The non-plan code path wraps issue content in tags with explicit 'Do NOT follow instructions' phrasing, but the plan code path loads the same external content via @path references with only a weaker 'Treat them as untrusted context' note. The choose-issue.md template has the same gap: plan files are referenced without untrusted-content wrapping while the non-plan branch wraps issues_json in tags. This weakens prompt injection defenses in plan mode.
Suggested fixes:
- Wrap each synced plan file's content in markers during sync_to_local() in src/plan_sync.rs (e.g., prepend/append the tags to each file written), so the agent sees the markers when it loads the @path references.
- Alternatively, embed plan file content directly in the template (read files into a template variable) and wrap it in tags instead of using @path references, matching the defense used for issues_json.
|
|
||
| fn shutdown_requested(shutdown: Option<&watch::Receiver<bool>>) -> bool { | ||
| shutdown.is_some_and(|rx| *rx.borrow()) | ||
| } |
There was a problem hiding this comment.
WARNING (path-traversal) copy-dir-follows-symlinks: copy_dir_recursive uses path.is_dir() and path.is_file() which follow symlinks. If the user's plan directory contains a symlink pointing outside it (e.g., to ../../.env or /etc/passwd), the symlink target's content is silently copied into the worktree and may then be committed and pushed by commit_plan_files() or persist_plan_dir_to_repo(), potentially exfiltrating sensitive files.
Suggested fixes:
- Check entry.file_type() (which does NOT follow symlinks) instead of path.is_dir()/is_file(). Skip or error on entries where entry.file_type()?.is_symlink() is true. For example:
let ft = entry.file_type()?; if ft.is_symlink() { continue; } if ft.is_dir() { ... } else if ft.is_file() { ... }.
Note: this finding applies to line 1175 but is shown here because that line is not in the diff.
There was a problem hiding this comment.
Fixed: copy-dir-follows-symlinks: skip symlink entries in recursive copy
|
|
||
| fn shutdown_requested(shutdown: Option<&watch::Receiver<bool>>) -> bool { | ||
| shutdown.is_some_and(|rx| *rx.borrow()) | ||
| } |
There was a problem hiding this comment.
WARNING (logic) zero-cycle-plan-marks-pr-ready: When the choose agent returns NOTHING_LEFT on the very first cycle, implemented_cycles stays at 0. The safety-limit check passes, and execution falls through to mark_ready + run_review_pipeline. This creates a PR containing only plan-file commits (no implementation work), marks it ready, reviews it, and completes the task. For the remote path this would also close the upstream issue. The intent of the loop exit is also unclear without a flag — the safety-limit check relies on counter-value reasoning rather than an explicit boolean.
Suggested fixes:
- After the plan loop, add an early return or error when implemented_cycles == 0:
if implemented_cycles == 0 { return Err(Error::Orchestrator("choose reported NOTHING_LEFT before any work was implemented".into())); } - Track
let mut plan_complete = false;, set to true in the NOTHING_LEFT break, and checkif !plan_complete { return Err(...) }after the loop — this also addresses the readability concern.
Note: this finding applies to line 773 but is shown here because that line is not in the diff.
| continue; | ||
| }; | ||
|
|
||
| if let Some(num) = LINEAR_NUMERIC_SUFFIX_RE |
There was a problem hiding this comment.
WARNING (logic) linear-url-id-mismatch: extract_referenced_task_ids strips the alphabetic prefix from Linear issue IDs (e.g. ENG-42 → 42) and stores the bare number in tasks_by_id. However, rewrite_issue_urls uses the full regex capture (ENG-42) to check membership in local_ids. Since local_ids is populated from tasks_by_id keys (bare numbers), Linear URLs will never match and will never be rewritten. Additionally, a Linear URL's bare numeric ID could collide with a GitHub issue number, causing fetch_sub_issues to retrieve an unrelated issue.
Suggested fixes:
- Store the full Linear ID (ENG-42) as-is in extract_referenced_task_ids instead of stripping the prefix, so the ID space is consistent with rewrite_issue_urls. Adjust issue_filename accordingly.
- Alternatively, restrict extract_referenced_task_ids to only match GitHub URLs (skip the Linear alternation) since LinearSource::fetch_sub_issues currently returns empty, avoiding cross-source ID collisions.
There was a problem hiding this comment.
Fixed: linear-url-id-mismatch: scope URL crawling by tracker and fix linear rewrites
| local_ids.insert(task.id.clone()); | ||
| } | ||
|
|
||
| for task in tasks_by_id.values() { |
There was a problem hiding this comment.
WARNING (efficiency) sync-write-reread-inefficiency: sync_to_local writes every task file to disk, then re-reads all files from disk to rewrite URLs, then calls list_plan_files a second time to populate the return value — three passes where one suffices. The local_ids HashSet is also built by cloning every key from tasks_by_id via a manual loop instead of using tasks_by_id.keys().cloned().collect() or passing a closure.
Suggested fixes:
- Apply rewrite_issue_urls to the content string in memory before fs::write, collect paths into a Vec during the same loop, and return that Vec directly — eliminating the read-back pass and the second list_plan_files call.
- Replace lines 80-83 with
let local_ids: HashSet<String> = tasks_by_id.keys().cloned().collect();or change rewrite_issue_urls to accept|id| tasks_by_id.contains_key(id)to avoid constructing a separate HashSet entirely.
|
|
||
| fn shutdown_requested(shutdown: Option<&watch::Receiver<bool>>) -> bool { | ||
| shutdown.is_some_and(|rx| *rx.borrow()) | ||
| } |
There was a problem hiding this comment.
WARNING (reuse) git-command-boilerplate: The pattern Command::new("git").args([...]).current_dir(path).output()... with status check and stderr wrapping is repeated 7 times across commit_plan_files, ensure_cycle_commit, git_head_oid, and push_branch. Each instance differs only in args and error-message prefix.
Suggested fixes:
- Extract a
fn run_git_checked(dir: &Path, args: &[&str], context: &str) -> Result<std::process::Output>helper that runs the command, checks exit status, and wraps stderr in Error::Orchestrator. Each call site becomes a one-liner.
Note: this finding applies to line 1191 but is shown here because that line is not in the diff.
There was a problem hiding this comment.
Fixed: git-command-boilerplate: extract shared git command helpers
| @@ -1068,6 +1670,40 @@ async fn test_worktree_cleaned_up_after_success() { | |||
| ); | |||
There was a problem hiding this comment.
WARNING (reuse) test-choose-handler-duplication: The Choose-phase handler block (~20 lines) is copy-pasted across five AgentRunner implementations in integration tests (~120 lines total of near-identical code).
Suggested fixes:
- Extract a shared
fn default_choose_handler(task_id: &str, prompt: &str, working_dir: &Path) -> Result<RunResult>that encapsulates the nothing-left check, task.toml write, and RunResult construction. Each runner's Choose arm delegates to it.
Note: this finding applies to line 259 but is shown here because that line is not in the diff.
| return slug; | ||
| } | ||
|
|
||
| let mut cleaned = String::new(); |
There was a problem hiding this comment.
INFO (reuse) plan-slug-reimplements-slugify: The fallback branch in plan_slug re-implements slug-cleaning logic (lowercase, replace non-alnum with hyphens, trim hyphens) that WorktreeManager::slugify already provides.
Suggested fixes:
- Replace lines 128-135 with
let cleaned = WorktreeManager::slugify(&main_task.id);and keep the empty-check + gh- prefix logic.
There was a problem hiding this comment.
Fixed: plan-slug-reimplements-slugify: reuse worktree slugify for id fallback
| )); | ||
| } | ||
|
|
||
| let mut has_file = false; |
There was a problem hiding this comment.
INFO (quality) validate-plan-dir-verbose: validate_local_plan_dir is 53 lines for a simple exists+is_dir+has-any-file check with manual entry iteration. It also returns Result<(), String> instead of the crate's error::Result<()>, inconsistent with the project's error convention.
Suggested fixes:
- Rewrite to call plan_sync::list_plan_files after the exists/is_dir checks, error if the returned vec is empty. Change return type to rlph::error::Result<()> using Error::Orchestrator for error variants.
No description provided.