Skip to content

Commit 04322ae

Browse files
ccchowclaude
andcommitted
feat: add FSD (Full Speed Drive) execution mode — autopilot without safeguards
Adds "fsd" as a third ExecutionMode alongside "manual" and "autopilot". FSD runs the autopilot loop with all safeguard checks disabled (no-progress, resume-cap, same-action-repeat) for maximum throughput. Includes end-to-end implementation: backend types, route validation, autopilot loop skip logic, frontend UI toggle (3-way cycle), PauseBanner FSD labels, and comprehensive tests for all changed components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c21860e commit 04322ae

File tree

13 files changed

+308
-58
lines changed

13 files changed

+308
-58
lines changed

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ Full lists: [`docs/CODING-GOTCHAS.md`](docs/CODING-GOTCHAS.md), [`docs/TESTING-G
9494
- **In-memory queue vs SQLite**: `workspaceQueues`/`workspacePendingTasks` are in-memory only.
9595
- **Autopilot runs inside workspace queue**: `runAutopilotLoop` is wrapped in `enqueueBlueprintTask` at call sites. Inside the loop, use `executeNodeDirect` (not `executeNode`) to avoid nested enqueue deadlock. `resumeNodeSession()` requires an `executionId` — look up latest execution with a session via `getExecutionsForNode()`.
9696
- **BlueprintStatus vs MacroNodeStatus naming**: `BlueprintStatus` uses `"draft"/"approved"`, `MacroNodeStatus` uses `"pending"/"queued"/"running"/"done"/"failed"/"blocked"/"skipped"` (notably `"running"` not `"in_progress"`).
97+
- **ExecutionMode values**: `"manual" | "autopilot" | "fsd"`. The `isAutopilotMode()` helper in `plan-routes.ts` groups `"autopilot"` and `"fsd"` together — switching between them does NOT trigger `switchingToAutopilot` (no re-enqueue). FSD skips all safeguard checks in the autopilot loop (`skipSafeguards = isFsd`).
9798
- **AI operations in plan-operations.ts**: `enrichNodeInternal`, `reevaluateNodeInternal`, `splitNodeInternal`, `smartDepsInternal`, `reevaluateAllInternal` are extracted from route handlers. Both `plan-routes.ts` and `autopilot.ts` call these directly. `runWithRelatedSessionDetection` helper also lives here.
9899
- **Autopilot pause/resume flow**: When resuming from a safeguard pause, `PauseBanner.handleResume` must clear `pauseReason` and set `status: "running"` (both via API and optimistically in local state). `runAutopilotLoop` also clears `pauseReason` on start. The PUT endpoint's `switchingToAutopilot` only fires when `executionMode` changes FROM non-autopilot, so re-entering autopilot from a paused-autopilot state uses `runAllNodes` instead.
99100
- **Circular dependency: autopilot ↔ plan-executor**: `autopilot.ts` imports from `plan-executor.ts`. If `plan-executor.ts` needs to call autopilot (e.g. recovery), use dynamic `import("./autopilot.js")` to avoid circular import.

backend/src/__tests__/plan-routes.test.ts

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ vi.mock("node:fs", async (importOriginal) => {
2323
// Allow /test and /test/CLAUDE.md to pass validation
2424
const np = p.replace(/\\/g, "/");
2525
if (np === "/test" || np === "/test/CLAUDE.md") return true;
26+
// /no-claude-md exists as a directory but has no CLAUDE.md
27+
if (np === "/no-claude-md") return true;
2628
return actual.existsSync(p);
2729
}),
2830
statSync: vi.fn((p: string) => {
29-
if (p.replace(/\\/g, "/") === "/test") return { isDirectory: (): boolean => true };
31+
const np = p.replace(/\\/g, "/");
32+
if (np === "/test" || np === "/no-claude-md") return { isDirectory: (): boolean => true };
3033
return actual.statSync(p);
3134
}),
3235
};
@@ -482,6 +485,22 @@ describe("plan-routes", () => {
482485
expect(res.body.defaultRole).toBe("qa");
483486
});
484487

488+
it("returns 400 when claude agent and no CLAUDE.md", async () => {
489+
const res = await request(app)
490+
.post("/api/blueprints")
491+
.send({ title: "No Claude MD", projectCwd: "/no-claude-md" });
492+
expect(res.status).toBe(400);
493+
expect(res.body.error).toContain("CLAUDE.md");
494+
});
495+
496+
it("skips CLAUDE.md check for non-claude agent types", async () => {
497+
const res = await request(app)
498+
.post("/api/blueprints")
499+
.send({ title: "OpenClaw BP", projectCwd: "/no-claude-md", agentType: "openclaw" });
500+
expect(res.status).toBe(201);
501+
expect(res.body.title).toBe("OpenClaw BP");
502+
});
503+
485504
it("defaults role fields when not provided", async () => {
486505
const res = await request(app)
487506
.post("/api/blueprints")
@@ -2516,6 +2535,80 @@ describe("plan-routes", () => {
25162535
expect(enqueueBlueprintTask).not.toHaveBeenCalled();
25172536
});
25182537

2538+
it("clears pauseReason and enqueues autopilot loop when switching to fsd on approved blueprint", async () => {
2539+
vi.mocked(getBlueprint).mockReturnValueOnce({
2540+
id: "bp-1",
2541+
title: "Test",
2542+
description: "desc",
2543+
status: "approved",
2544+
executionMode: undefined,
2545+
pauseReason: "Some pause reason",
2546+
projectCwd: "/test",
2547+
nodes: [],
2548+
createdAt: "2024-01-01",
2549+
updatedAt: "2024-01-01",
2550+
} as any);
2551+
2552+
const res = await request(app)
2553+
.put("/api/blueprints/bp-1")
2554+
.send({ executionMode: "fsd" });
2555+
expect(res.status).toBe(200);
2556+
2557+
expect(updateBlueprint).toHaveBeenCalledWith("bp-1", expect.objectContaining({
2558+
executionMode: "fsd",
2559+
pauseReason: "",
2560+
}));
2561+
expect(enqueueBlueprintTask).toHaveBeenCalledWith("bp-1", expect.any(Function));
2562+
2563+
const enqueueCall = vi.mocked(enqueueBlueprintTask).mock.calls[0];
2564+
await enqueueCall[1]();
2565+
expect(runAutopilotLoop).toHaveBeenCalledWith("bp-1");
2566+
});
2567+
2568+
it("does NOT re-enqueue when already in fsd mode and setting fsd again", async () => {
2569+
vi.mocked(getBlueprint).mockReturnValueOnce({
2570+
id: "bp-1",
2571+
title: "Test",
2572+
description: "desc",
2573+
status: "approved",
2574+
executionMode: "fsd",
2575+
projectCwd: "/test",
2576+
nodes: [],
2577+
createdAt: "2024-01-01",
2578+
updatedAt: "2024-01-01",
2579+
} as any);
2580+
2581+
const res = await request(app)
2582+
.put("/api/blueprints/bp-1")
2583+
.send({ executionMode: "fsd" });
2584+
expect(res.status).toBe(200);
2585+
2586+
// Already fsd → not switching → no enqueue
2587+
expect(enqueueBlueprintTask).not.toHaveBeenCalled();
2588+
});
2589+
2590+
it("enqueues autopilot loop when switching from autopilot to fsd", async () => {
2591+
vi.mocked(getBlueprint).mockReturnValueOnce({
2592+
id: "bp-1",
2593+
title: "Test",
2594+
description: "desc",
2595+
status: "approved",
2596+
executionMode: "autopilot",
2597+
projectCwd: "/test",
2598+
nodes: [],
2599+
createdAt: "2024-01-01",
2600+
updatedAt: "2024-01-01",
2601+
} as any);
2602+
2603+
const res = await request(app)
2604+
.put("/api/blueprints/bp-1")
2605+
.send({ executionMode: "fsd" });
2606+
expect(res.status).toBe(200);
2607+
2608+
// autopilot → fsd: both are autopilot-like, so NOT a switch (isAutopilotMode → isAutopilotMode)
2609+
expect(enqueueBlueprintTask).not.toHaveBeenCalled();
2610+
});
2611+
25192612
it("returns 400 for invalid executionMode", async () => {
25202613
const res = await request(app)
25212614
.put("/api/blueprints/bp-1")
@@ -2574,6 +2667,31 @@ describe("plan-routes", () => {
25742667
expect(runAutopilotLoop).toHaveBeenCalledWith("bp-1", undefined);
25752668
});
25762669

2670+
it("routes to runAutopilotLoop when executionMode is fsd", async () => {
2671+
vi.mocked(getBlueprint).mockReturnValueOnce({
2672+
id: "bp-1",
2673+
title: "Test",
2674+
description: "desc",
2675+
status: "approved",
2676+
executionMode: "fsd",
2677+
projectCwd: "/test",
2678+
nodes: [],
2679+
createdAt: "2024-01-01",
2680+
updatedAt: "2024-01-01",
2681+
} as any);
2682+
2683+
const res = await request(app).post("/api/blueprints/bp-1/run-all");
2684+
expect(res.status).toBe(200);
2685+
expect(res.body.message).toBe("execution started");
2686+
2687+
expect(enqueueBlueprintTask).toHaveBeenCalledWith("bp-1", expect.any(Function));
2688+
expect(executeAllNodes).not.toHaveBeenCalled();
2689+
2690+
const enqueueCall = vi.mocked(enqueueBlueprintTask).mock.calls[0];
2691+
await enqueueCall[1]();
2692+
expect(runAutopilotLoop).toHaveBeenCalledWith("bp-1", undefined);
2693+
});
2694+
25772695
it("routes to executeAllNodes when executionMode is manual/undefined", async () => {
25782696
vi.mocked(getBlueprint).mockReturnValueOnce({
25792697
id: "bp-1",

backend/src/autopilot.ts

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,7 @@ export function buildAutopilotPrompt(
670670
iteration: number,
671671
maxIterations: number,
672672
memory: AutopilotMemory = { blueprint: null, global: null },
673+
fsdMode: boolean = false,
673674
): string {
674675
const remaining = maxIterations - iteration;
675676

@@ -682,17 +683,12 @@ export function buildAutopilotPrompt(
682683
memorySections += `\n## Blueprint Memory (your notes from earlier iterations)\n${memory.blueprint}\n`;
683684
}
684685

685-
return `You are the Autopilot agent for a software blueprint. Your goal is to drive this blueprint to completion by choosing the best next action at each step.
686-
687-
## Current Blueprint State
688-
${JSON.stringify(state, null, 2)}
689-
690-
## Iteration ${iteration} of ${maxIterations}
691-
${memorySections}
692-
## Available Tools
693-
${TOOL_DESCRIPTIONS}
694-
695-
## Recommended Workflow Rhythm
686+
const workflowSection = fsdMode
687+
? `## FSD Mode (Full Speed Drive)
688+
You are running in FSD mode — no safeguards, no throttling. Execute as fast and efficiently as possible.
689+
Focus on running nodes to completion. Skip enrichment and coordination overhead unless absolutely necessary.
690+
Don't hesitate to run nodes back-to-back. Maximize throughput.`
691+
: `## Recommended Workflow Rhythm
696692
Don't just run nodes back-to-back. Follow this quality-aware pattern:
697693
698694
1. **Before running a node**: If its description is short or vague, use enrich_node first to improve the prompt.
@@ -701,9 +697,15 @@ Don't just run nodes back-to-back. Follow this quality-aware pattern:
701697
4. **When suggestions accumulate**: Review them — create_node for real issues, batch_mark_suggestions_used for minor/addressed ones.
702698
5. **When multiple roles are enabled and a design decision is ambiguous**: Use convene(topic, roleIds) to get multi-perspective input.
703699
704-
A good rhythm: enrich → run → triage suggestions → repeat. Not every node needs all steps, but never do 5+ run_node calls in a row without a coordinate or suggestion triage in between.
700+
A good rhythm: enrich → run → triage suggestions → repeat. Not every node needs all steps, but never do 5+ run_node calls in a row without a coordinate or suggestion triage in between.`;
705701

706-
## Guidelines
702+
const guidelinesSection = fsdMode
703+
? `## Guidelines
704+
- Execute nodes in dependency order. Don't run a node whose dependencies aren't done.
705+
- If a node failed, try resume with feedback or skip it — don't get stuck on any single node.
706+
- When all nodes are done, call complete().
707+
- You have ${remaining} iterations left.`
708+
: `## Guidelines
707709
- Execute nodes in dependency order. Don't run a node whose dependencies aren't done.
708710
- If a node failed, analyze the error. Consider: resume with feedback, split it, modify its description/prompt, or skip it if non-critical.
709711
- If a node seems too complex (long description >500 chars, many dependencies), consider split_node first.
@@ -713,7 +715,21 @@ A good rhythm: enrich → run → triage suggestions → repeat. Not every node
713715
- If warning insights exist, consider addressing them when convenient but don't block progress.
714716
- If you're stuck or need a human decision (architectural choice, ambiguous requirement, external dependency), use pause(reason).
715717
- You have ${remaining} iterations left. Balance quality (coordinate, enrich, suggestion triage) with progress (run_node).
716-
- When all nodes are done: review any remaining unused suggestions. Use batch_mark_suggestions_used for ones not worth acting on. Use create_node for actionable ones. Then call complete().
718+
- When all nodes are done: review any remaining unused suggestions. Use batch_mark_suggestions_used for ones not worth acting on. Use create_node for actionable ones. Then call complete().`;
719+
720+
return `You are the Autopilot agent for a software blueprint. Your goal is to drive this blueprint to completion by choosing the best next action at each step.
721+
722+
## Current Blueprint State
723+
${JSON.stringify(state, null, 2)}
724+
725+
## Iteration ${iteration} of ${maxIterations}
726+
${memorySections}
727+
## Available Tools
728+
${TOOL_DESCRIPTIONS}
729+
730+
${workflowSection}
731+
732+
${guidelinesSection}
717733
718734
## Decision Format
719735
Respond with exactly one JSON object:
@@ -1234,7 +1250,8 @@ export async function runAutopilotLoop(blueprintId: string, options?: AutopilotL
12341250
clearResumeCounts(blueprintId);
12351251
clearSeenSuggestions(blueprintId);
12361252

1237-
const maxIterations = blueprint.maxIterations ?? 50;
1253+
const isFsdAtStart = blueprint.executionMode === "fsd";
1254+
const maxIterations = blueprint.maxIterations ?? (isFsdAtStart ? 200 : 50);
12381255
let iteration = 0;
12391256

12401257
// Memory: read existing per-blueprint and global memory at loop start
@@ -1249,7 +1266,7 @@ export async function runAutopilotLoop(blueprintId: string, options?: AutopilotL
12491266
graceIterations: options?.safeguardGrace ?? 0,
12501267
};
12511268

1252-
log.info(`Autopilot starting for blueprint ${blueprintId.slice(0, 8)} (max ${maxIterations} iterations)`);
1269+
log.info(`Autopilot starting for blueprint ${blueprintId.slice(0, 8)} (max ${maxIterations} iterations, mode: ${isFsdAtStart ? "fsd" : "autopilot"})`);
12531270

12541271
try {
12551272
while (iteration < maxIterations) {
@@ -1274,14 +1291,17 @@ export async function runAutopilotLoop(blueprintId: string, options?: AutopilotL
12741291

12751292
// Check if user switched to manual mode
12761293
const current = getBlueprint(blueprintId);
1277-
if (!current || current.executionMode !== "autopilot") {
1294+
const isFsd = current?.executionMode === "fsd";
1295+
if (!current || (current.executionMode !== "autopilot" && current.executionMode !== "fsd")) {
12781296
logAutopilot(blueprintId, iteration, state.summary, "Mode switched to manual", "paused");
12791297
log.info(`Autopilot stopped for ${blueprintId.slice(0, 8)}: mode switched to manual`);
12801298
break;
12811299
}
12821300

1301+
// FSD mode: skip all safeguard checks entirely
12831302
// Safeguard grace period: skip checks when user explicitly resumed from a safeguard pause
12841303
const inGracePeriod = safeguardState.graceIterations > 0;
1304+
const skipSafeguards = isFsd || inGracePeriod;
12851305
if (inGracePeriod) {
12861306
safeguardState.graceIterations--;
12871307
// Still track state for when grace period ends
@@ -1290,7 +1310,7 @@ export async function runAutopilotLoop(blueprintId: string, options?: AutopilotL
12901310
safeguardState.noProgressCount = 0;
12911311
}
12921312

1293-
if (!inGracePeriod) {
1313+
if (!skipSafeguards) {
12941314
// Check resume cap safeguard
12951315
const resumeCapReason = checkResumeCapExceeded(blueprintId, state);
12961316
if (resumeCapReason) {
@@ -1323,7 +1343,7 @@ export async function runAutopilotLoop(blueprintId: string, options?: AutopilotL
13231343
const prompt = buildAutopilotPrompt(state, iteration, maxIterations, {
13241344
blueprint: blueprintMemory,
13251345
global: globalMemory,
1326-
});
1346+
}, isFsd);
13271347
let decision: AutopilotDecision;
13281348
try {
13291349
decision = await callAgentForDecision(prompt, current.projectCwd);
@@ -1337,8 +1357,8 @@ export async function runAutopilotLoop(blueprintId: string, options?: AutopilotL
13371357

13381358
log.info(`Autopilot iteration ${iteration}: ${decision.action}${decision.reasoning}`);
13391359

1340-
// Check same-action safeguard (also skipped during grace period)
1341-
if (!inGracePeriod) {
1360+
// Check same-action safeguard (skipped in FSD mode and during grace period)
1361+
if (!skipSafeguards) {
13421362
const sameActionReason = checkSameActionRepeat(safeguardState, decision);
13431363
if (sameActionReason) {
13441364
updateBlueprint(blueprintId, {
@@ -1349,8 +1369,8 @@ export async function runAutopilotLoop(blueprintId: string, options?: AutopilotL
13491369
log.warn(`Autopilot paused: ${sameActionReason}`);
13501370
break;
13511371
}
1352-
} else {
1353-
// Still track actions during grace period for post-grace checks
1372+
} else if (!isFsd) {
1373+
// Still track actions during grace period for post-grace checks (not needed for FSD)
13541374
checkSameActionRepeat(safeguardState, decision);
13551375
}
13561376

backend/src/plan-db.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export type ExecutionType = "primary" | "retry" | "continuation" | "subtask";
1313
export type ExecutionStatus = "running" | "done" | "failed" | "cancelled";
1414
export type FailureReason = "timeout" | "context_exhausted" | "output_token_limit" | "hung" | "error" | null;
1515
export type ReportedStatus = "done" | "failed" | "blocked" | null;
16-
export type ExecutionMode = "manual" | "autopilot";
16+
export type ExecutionMode = "manual" | "autopilot" | "fsd";
1717
export type RelatedSessionType = "enrich" | "reevaluate" | "split" | "evaluate" | "reevaluate_all" | "generate" | "smart_deps" | "coordinate";
1818
export type InsightSeverity = "info" | "warning" | "critical";
1919
export type ConveneSessionStatus = "active" | "synthesizing" | "completed" | "cancelled" | "failed";

0 commit comments

Comments
 (0)