From 1dbdc170cc35a4ed08e09041bfb135b3264f212b Mon Sep 17 00:00:00 2001 From: Gregory Fong Date: Sat, 21 Mar 2026 23:45:29 -0700 Subject: [PATCH] fix: clear directive flags on decomposition failure + dismiss endpoint When run_directive() fails (timeout, agent error, parse failure), the project was left stuck with active_directive_sk set, blocking autopilot permanently. Now _clear_directive_on_failure() resets the project to awaiting_next_directive so the next autopilot cycle can proceed. Also adds POST /api/projects/:id/directive/dismiss for manually clearing a stuck directive from the UI. Made-with: Cursor --- frontend/src/lib/api.ts | 6 ++++ frontend/src/pages/ProjectDetail.tsx | 35 +++++++++++++++++++++++ infra/packages/api/src/routes/projects.ts | 13 +++++++++ src/pipeline.py | 21 ++++++++++++++ 4 files changed, 75 insertions(+) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2254dd2..149b65d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -322,6 +322,12 @@ export async function postProjectDirective(id: string, content: string) { }) } +export async function dismissProjectDirective(id: string) { + return request<{ ok: boolean }>(`/projects/${id}/directive/dismiss`, { + method: "POST", + }) +} + // --------------------------------------------------------------------------- // Snapshots, Proposals, Human Requests // --------------------------------------------------------------------------- diff --git a/frontend/src/pages/ProjectDetail.tsx b/frontend/src/pages/ProjectDetail.tsx index 1b55a59..1683e38 100644 --- a/frontend/src/pages/ProjectDetail.tsx +++ b/frontend/src/pages/ProjectDetail.tsx @@ -47,6 +47,7 @@ import { reviewAutopilotCycle, fetchProjectChat, postProjectChat, + dismissProjectDirective, } from "@/lib/api" import type { Snapshot } from "@/lib/api" import type { @@ -1180,6 +1181,40 @@ export default function ProjectDetail() { New directive + {(() => { + const activeDir = directives.find((d) => d.sk === project.active_directive_sk) + const stuck = + project.active_directive_sk && + !project.awaiting_next_directive && + activeDir && + activeDir.task_ids.length === 0 + if (!stuck) return null + return ( +
+ + + Directive decomposition failed — no tasks were created. Autopilot is blocked. + + +
+ ) + })()} {directives.length === 0 ? (

No directives yet.

) : ( diff --git a/infra/packages/api/src/routes/projects.ts b/infra/packages/api/src/routes/projects.ts index c4b8b4c..ae8fa21 100644 --- a/infra/packages/api/src/routes/projects.ts +++ b/infra/packages/api/src/routes/projects.ts @@ -255,6 +255,19 @@ projects.post("/projects/:id/directive", async (c) => { return c.json({ ok: true, directive }); }); +// POST /projects/:id/directive/dismiss — clear stuck directive so autopilot can proceed +projects.post("/projects/:id/directive/dismiss", async (c) => { + const p = await db.getProject(c.req.param("id")); + if (!p) return c.json({ error: "not found" }, 404); + if (!p.active_directive_sk) { + return c.json({ error: "no active directive to dismiss" }, 400); + } + await db.updateProject(p.id, { + awaiting_next_directive: true, + }); + return c.json({ ok: true }); +}); + // --------------------------------------------------------------------------- // Snapshots // --------------------------------------------------------------------------- diff --git a/src/pipeline.py b/src/pipeline.py index 7388105..3ef17bb 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -472,6 +472,19 @@ def run_plan_only(store, task): return True +def _clear_directive_on_failure(project_id, directive_sk): + # type: (str, str) -> None + """Reset project directive flags so autopilot isn't permanently blocked by a failed decomposition.""" + try: + from .projects_dynamo import update_project + + update_project(project_id, {"awaiting_next_directive": True}) + log.info("run_directive: cleared directive flags for project %s after failure", project_id) + plog(project_id, "directive_failed", "plan", "Decomposition failed for %s" % directive_sk) + except Exception: + log.exception("run_directive: failed to clear directive flags for project %s", project_id) + + def run_directive(store, project_id, directive_sk): # type: (Any, str, str) -> bool """Decompose a project directive into top-level tasks (plan-only style), then dispatch runners.""" @@ -483,10 +496,12 @@ def run_directive(store, project_id, directive_sk): proj = get_project(project_id) if not proj: log.error("run_directive: project %s not found", project_id) + _clear_directive_on_failure(project_id, directive_sk) return False ditem = get_directive_item(project_id, directive_sk) if not ditem: log.error("run_directive: directive %s not found for project %s", directive_sk, project_id) + _clear_directive_on_failure(project_id, directive_sk) return False spec = (proj.get("spec") or "").strip() or "(no spec)" @@ -518,31 +533,37 @@ def run_directive(store, project_id, directive_sk): ) except subprocess.TimeoutExpired: log.warning("run_directive: timed out for project %s", project_id) + _clear_directive_on_failure(project_id, directive_sk) return False except Exception: log.exception("run_directive: agent error for project %s", project_id) + _clear_directive_on_failure(project_id, directive_sk) return False if result.returncode != 0: log.warning( "run_directive: agent failed project %s (exit %d)", project_id, result.returncode ) + _clear_directive_on_failure(project_id, directive_sk) return False text = _extract_agent_text(result.stdout) json_match = re.search(r"\[.*\]", text, re.DOTALL) if not json_match: log.warning("run_directive: no JSON for project %s", project_id) + _clear_directive_on_failure(project_id, directive_sk) return False try: steps = _json.loads(json_match.group()) except _json.JSONDecodeError: log.exception("run_directive: JSON parse error for project %s", project_id) + _clear_directive_on_failure(project_id, directive_sk) return False if not isinstance(steps, list) or not steps: log.warning("run_directive: empty plan for project %s", project_id) + _clear_directive_on_failure(project_id, directive_sk) return False spec_excerpt = spec if len(spec) <= 2000 else spec[:2000] + "… (truncated)"