feat(FN-1499): add workflow step revision loop signaling
- Add WorkflowStepSignal type with 'needs-revision' support for iterative QA loops - Extend executeWorkflowStep() to detect needs-revision signals and trigger revision sessions - Add revision loop limit (5 iterations max) to prevent infinite loops - Track revisionAttempts in workflow step results - Add comprehensive tests for revision signaling, loop behavior, and limit enforcement - Document workflow step revision behavior in docs/workflow-steps.md
This commit is contained in:
@@ -3,6 +3,17 @@
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- `TaskExecutor` terminates active agent sessions (single and step) when tasks are moved away from `in-progress` via the `task:moved` event handler. This prevents zombie sessions when users manually send tasks back to todo/triage from the board UI.
|
- `TaskExecutor` terminates active agent sessions (single and step) when tasks are moved away from `in-progress` via the `task:moved` event handler. This prevents zombie sessions when users manually send tasks back to todo/triage from the board UI.
|
||||||
|
- **Workflow Step Revision Loop (FN-1499)**: Workflow steps can request implementation revisions via "REQUEST REVISION" output. The flow:
|
||||||
|
1. Workflow step agent outputs "REQUEST REVISION\n\n[feedback]" to signal that code changes are needed
|
||||||
|
2. `executeWorkflowStep()` detects this pattern and returns `WorkflowStepOutcome` with `revisionRequested: true`
|
||||||
|
3. `runWorkflowSteps()` propagates the structured outcome with `WorkflowStepResult.revisionRequested`
|
||||||
|
4. `handleWorkflowRevisionRequest()` is called, which:
|
||||||
|
- Injects "Workflow Revision Instructions" section into `PROMPT.md` (replacing any prior revision block)
|
||||||
|
- Resets all steps to `pending` for fresh execution
|
||||||
|
- Clears session file to get a fresh agent session
|
||||||
|
- Schedules fresh execution via `setTimeout` after current guard unwinds
|
||||||
|
5. Task stays in `in-progress` and the scheduler re-dispatches the task for a fresh executor pass
|
||||||
|
6. **Guard-unwind requirement**: The revision rerun MUST be scheduled after the current `execute()` guard clears (`this.executing.delete()`). Failure to observe this causes a race where the scheduler re-dispatches while the old execution guard is still set, silently no-oping the new dispatch and stranding the task in `in-progress` with no active session.
|
||||||
- **Review Handoff (FN-1259)**: Agents can hand off tasks to users for human review via steering comments containing handoff phrases ("send it back to me", "hand off to user", etc.). When `reviewHandoffPolicy` is `"comment-triggered"`, the executor detects handoff intent in agent-authored steering comments and executes the handoff: sets `status: "awaiting-user-review"`, `assigneeUserId: "requesting-user"`, moves task to `in-review`, and disposes the agent session. The merger skips tasks with `"awaiting-user-review"` status (via `BLOCKING_TASK_STATUSES` in `task-merge.ts`). Users can accept review (clear status) or return to agent (move to todo). The `assigneeUserId` field stores the user ID who should review the task.
|
- **Review Handoff (FN-1259)**: Agents can hand off tasks to users for human review via steering comments containing handoff phrases ("send it back to me", "hand off to user", etc.). When `reviewHandoffPolicy` is `"comment-triggered"`, the executor detects handoff intent in agent-authored steering comments and executes the handoff: sets `status: "awaiting-user-review"`, `assigneeUserId: "requesting-user"`, moves task to `in-review`, and disposes the agent session. The merger skips tasks with `"awaiting-user-review"` status (via `BLOCKING_TASK_STATUSES` in `task-merge.ts`). Users can accept review (clear status) or return to agent (move to todo). The `assigneeUserId` field stores the user ID who should review the task.
|
||||||
- Agent preset templates in `NewAgentDialog.tsx` are a UI-only concept (`AgentPreset` interface), separate from the engine's `AgentPromptTemplate` type. Presets populate agent creation fields (name, icon, role, soul, instructionsText) but don't map to engine types.
|
- Agent preset templates in `NewAgentDialog.tsx` are a UI-only concept (`AgentPreset` interface), separate from the engine's `AgentPromptTemplate` type. Presets populate agent creation fields (name, icon, role, soul, instructionsText) but don't map to engine types.
|
||||||
- `soul` and `instructionsText` are already supported in `AgentCreateInput` and `AgentUpdateInput` — no API changes needed when adding these to presets.
|
- `soul` and `instructionsText` are already supported in `AgentCreateInput` and `AgentUpdateInput` — no API changes needed when adding these to presets.
|
||||||
|
|||||||
@@ -541,6 +541,8 @@ Edit mode never auto-injects workflow steps — existing task selections are pre
|
|||||||
4. Post-merge steps run automatically after merge — results appear in the Workflow tab
|
4. Post-merge steps run automatically after merge — results appear in the Workflow tab
|
||||||
5. View results in the task detail modal's **Workflow** tab
|
5. View results in the task detail modal's **Workflow** tab
|
||||||
|
|
||||||
|
**Revision Loop:** If a pre-merge step identifies issues that need code changes, it can request a revision. The executor generates "Workflow Revision Instructions" in the task's PROMPT.md, resets all steps, and routes the task back to in-progress for a fresh implementation pass. Hard failures (e.g., broken test infrastructure) still move the task to in-review with a failed status.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
|
|||||||
@@ -59,6 +59,51 @@ Workflow step definitions support `defaultOn`.
|
|||||||
|
|
||||||
When `defaultOn: true`, the step is preselected automatically for newly created tasks (users can still deselect it).
|
When `defaultOn: true`, the step is preselected automatically for newly created tasks (users can still deselect it).
|
||||||
|
|
||||||
|
## Workflow Step Revision Loop
|
||||||
|
|
||||||
|
Workflow steps can request implementation revisions instead of just blocking completion.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
When a prompt-mode workflow step agent finishes its review, it can output a **revision request** to indicate that code changes are needed:
|
||||||
|
|
||||||
|
```
|
||||||
|
REQUEST REVISION
|
||||||
|
|
||||||
|
Fix the SQL injection vulnerability in src/auth.ts. The login function does not
|
||||||
|
handle the case where the user account is locked.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
When a revision is requested:
|
||||||
|
|
||||||
|
1. The executor generates a **Workflow Revision Instructions** section in the task's `PROMPT.md`
|
||||||
|
2. All step statuses are reset to `pending` for a fresh execution pass
|
||||||
|
3. The task remains in `in-progress` and a fresh executor session is scheduled
|
||||||
|
4. The agent receives the revision feedback at the start of its next session
|
||||||
|
|
||||||
|
### Feedback Format
|
||||||
|
|
||||||
|
Workflow step prompts should instruct agents to use this exact format for revision requests:
|
||||||
|
|
||||||
|
```
|
||||||
|
REQUEST REVISION
|
||||||
|
|
||||||
|
[Clear, actionable description of what needs to be fixed]
|
||||||
|
```
|
||||||
|
|
||||||
|
The revision block replaces any prior revision instructions (no accumulation).
|
||||||
|
|
||||||
|
### Hard Failures vs Revisions
|
||||||
|
|
||||||
|
Not all workflow failures are revision requests:
|
||||||
|
|
||||||
|
- **Revision requested**: Implementation needs changes → routes back to executor
|
||||||
|
- **Hard failure**: Unrecoverable issue → moves to `in-review` with `failed` status
|
||||||
|
|
||||||
|
Use revision requests when the implementation is wrong but fixable. Use hard failures only for genuine blockers (e.g., required files are missing, test infrastructure is broken).
|
||||||
|
|
||||||
## Viewing Results
|
## Viewing Results
|
||||||
|
|
||||||
Task detail modal includes a **Workflow** tab when workflow data exists.
|
Task detail modal includes a **Workflow** tab when workflow data exists.
|
||||||
|
|||||||
@@ -7138,6 +7138,220 @@ describe("Workflow Steps Execution", () => {
|
|||||||
// Task should still move to in-review
|
// Task should still move to in-review
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Workflow Step Revision Request ──────────────────────────────────
|
||||||
|
|
||||||
|
it("workflow step agent returns revisionRequested when output starts with REQUEST REVISION", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
|
||||||
|
store.getTask.mockResolvedValue({
|
||||||
|
id: "FN-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test task",
|
||||||
|
column: "in-progress",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [{ name: "Preflight", status: "pending" }],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
enabledWorkflowSteps: ["WS-001"],
|
||||||
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
store.getWorkflowStep.mockResolvedValue({
|
||||||
|
id: "WS-001",
|
||||||
|
name: "Security Audit",
|
||||||
|
description: "Check for vulnerabilities",
|
||||||
|
prompt: "Scan for security issues.",
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// First call: main agent with task_done
|
||||||
|
// Second call: workflow step agent that returns REQUEST REVISION
|
||||||
|
let callIdx = 0;
|
||||||
|
let subscribeHandler: any;
|
||||||
|
mockedCreateHaiAgent.mockImplementation((async (opts: any) => {
|
||||||
|
callIdx++;
|
||||||
|
if (callIdx === 1) {
|
||||||
|
// Main execution
|
||||||
|
const customTools = opts.customTools || [];
|
||||||
|
const session = {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
||||||
|
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||||||
|
state: {},
|
||||||
|
};
|
||||||
|
return { session };
|
||||||
|
} else {
|
||||||
|
// Workflow step agent that requests revision
|
||||||
|
const session = {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
// Call the subscribe handler to simulate agent outputting REQUEST REVISION
|
||||||
|
if (subscribeHandler) {
|
||||||
|
subscribeHandler({
|
||||||
|
type: "message_update",
|
||||||
|
assistantMessageEvent: { type: "text_delta", delta: "REQUEST REVISION\n\nFix the SQL injection vulnerability in src/auth.ts" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
subscribe: vi.fn((handler: any) => {
|
||||||
|
subscribeHandler = handler;
|
||||||
|
}),
|
||||||
|
state: {},
|
||||||
|
};
|
||||||
|
return { session };
|
||||||
|
}
|
||||||
|
}) as any);
|
||||||
|
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||||||
|
|
||||||
|
await executor.execute({
|
||||||
|
id: "FN-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test task",
|
||||||
|
column: "in-progress",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [{ name: "Preflight", status: "pending" }],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
enabledWorkflowSteps: ["WS-001"],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both agents should be called
|
||||||
|
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Log should show revision was requested
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
|
"FN-001",
|
||||||
|
expect.stringContaining("requested revision"),
|
||||||
|
expect.stringContaining("SQL injection"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Workflow step result should be marked as failed
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith(
|
||||||
|
"FN-001",
|
||||||
|
expect.objectContaining({
|
||||||
|
workflowStepResults: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
workflowStepId: "WS-001",
|
||||||
|
status: "failed",
|
||||||
|
output: expect.stringContaining("SQL injection"),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Task should NOT be marked as failed (revision requested, not hard failure)
|
||||||
|
expect(store.updateTask).not.toHaveBeenCalledWith(
|
||||||
|
"FN-001",
|
||||||
|
expect.objectContaining({ status: "failed", error: "Workflow step failed" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hard failure workflow step moves task to in-review with failed status", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
|
||||||
|
store.getTask.mockResolvedValue({
|
||||||
|
id: "FN-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test task",
|
||||||
|
column: "in-progress",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [{ name: "Preflight", status: "pending" }],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
enabledWorkflowSteps: ["WS-001"],
|
||||||
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
store.getWorkflowStep.mockResolvedValue({
|
||||||
|
id: "WS-001",
|
||||||
|
name: "QA Check",
|
||||||
|
description: "Run tests",
|
||||||
|
prompt: "Run the test suite.",
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let callIdx = 0;
|
||||||
|
mockedCreateHaiAgent.mockImplementation((async (opts: any) => {
|
||||||
|
callIdx++;
|
||||||
|
if (callIdx === 1) {
|
||||||
|
const customTools = opts.customTools || [];
|
||||||
|
const session = {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
||||||
|
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||||||
|
state: {},
|
||||||
|
};
|
||||||
|
return { session };
|
||||||
|
} else {
|
||||||
|
// Workflow step agent that passes (no REQUEST REVISION)
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
state: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}) as any);
|
||||||
|
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
const onError = vi.fn();
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test", { onComplete, onError });
|
||||||
|
|
||||||
|
await executor.execute({
|
||||||
|
id: "FN-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test task",
|
||||||
|
column: "in-progress",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [{ name: "Preflight", status: "pending" }],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
enabledWorkflowSteps: ["WS-001"],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both agents called
|
||||||
|
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Log should show workflow step passed
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
|
"FN-001",
|
||||||
|
expect.stringContaining("Workflow step completed"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Task should move to in-review (not revision loop)
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||||||
|
|
||||||
|
// onComplete should be called
|
||||||
|
expect(onComplete).toHaveBeenCalled();
|
||||||
|
expect(onError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Real-time steering injection", () => {
|
describe("Real-time steering injection", () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
import type { TaskStore, Task, TaskDetail, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext } from "@fusion/core";
|
import type { TaskStore, Task, TaskDetail, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext } from "@fusion/core";
|
||||||
import type { AgentStore } from "@fusion/core";
|
import type { AgentStore } from "@fusion/core";
|
||||||
import { buildExecutionMemoryInstructions, getTaskMergeBlocker, resolveAgentPrompt } from "@fusion/core";
|
import { buildExecutionMemoryInstructions, getTaskMergeBlocker, resolveAgentPrompt } from "@fusion/core";
|
||||||
@@ -88,6 +89,27 @@ interface SpawnAgentResult {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of a single workflow step execution.
|
||||||
|
* Supports three states: pass, hard failure, or revision requested with feedback.
|
||||||
|
*/
|
||||||
|
export interface WorkflowStepOutcome {
|
||||||
|
success: boolean;
|
||||||
|
revisionRequested?: boolean;
|
||||||
|
output?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of running all pre-merge workflow steps.
|
||||||
|
* Returns true if all passed, false if any hard failure, or a structured
|
||||||
|
* revision result if a revision was requested.
|
||||||
|
*/
|
||||||
|
export type WorkflowStepResult =
|
||||||
|
| { allPassed: true }
|
||||||
|
| { allPassed: false; revisionRequested: false }
|
||||||
|
| { allPassed: false; revisionRequested: true; feedback: string; stepName: string };
|
||||||
|
|
||||||
|
|
||||||
const reviewStepParams = Type.Object({
|
const reviewStepParams = Type.Object({
|
||||||
step: Type.Number({ description: "Step number to review" }),
|
step: Type.Number({ description: "Step number to review" }),
|
||||||
@@ -656,8 +678,9 @@ export class TaskExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run workflow steps before transitioning
|
// Run workflow steps before transitioning
|
||||||
const workflowSuccess = await this.runWorkflowSteps(task, task.worktree, settings);
|
const workflowResult = await this.runWorkflowSteps(task, task.worktree, settings);
|
||||||
if (!workflowSuccess) {
|
if (!workflowResult.allPassed) {
|
||||||
|
// For recovery path, treat any failure (including revision) as hard failure
|
||||||
await this.store.updateTask(task.id, { status: "failed", error: "Workflow step failed during recovery" });
|
await this.store.updateTask(task.id, { status: "failed", error: "Workflow step failed during recovery" });
|
||||||
await this.store.moveTask(task.id, "in-review");
|
await this.store.moveTask(task.id, "in-review");
|
||||||
executorLog.log(`✗ ${task.id} workflow step failed during recovery → in-review`);
|
executorLog.log(`✗ ${task.id} workflow step failed during recovery → in-review`);
|
||||||
@@ -1082,8 +1105,14 @@ export class TaskExecutor {
|
|||||||
await audit.filesystem({ type: "file:capture-modified", target: task.id, metadata: { files: modifiedFiles } });
|
await audit.filesystem({ type: "file:capture-modified", target: task.id, metadata: { files: modifiedFiles } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const workflowSuccess = await this.runWorkflowSteps(task, worktreePath, settings);
|
const workflowResult = await this.runWorkflowSteps(task, worktreePath, settings);
|
||||||
if (!workflowSuccess) {
|
if (!workflowResult.allPassed) {
|
||||||
|
// Check if revision was requested
|
||||||
|
if (workflowResult.revisionRequested) {
|
||||||
|
await this.handleWorkflowRevisionRequest(task, worktreePath, workflowResult.feedback, workflowResult.stepName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Hard failure - move to in-review
|
||||||
await this.store.updateTask(task.id, { status: "failed", error: "Workflow step failed" });
|
await this.store.updateTask(task.id, { status: "failed", error: "Workflow step failed" });
|
||||||
await this.store.moveTask(task.id, "in-review");
|
await this.store.moveTask(task.id, "in-review");
|
||||||
// Audit trail: record task move (FN-1404)
|
// Audit trail: record task move (FN-1404)
|
||||||
@@ -1493,9 +1522,14 @@ export class TaskExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run workflow steps before moving to in-review
|
// Run workflow steps before moving to in-review
|
||||||
const workflowSuccess = await this.runWorkflowSteps(task, worktreePath, settings);
|
const workflowResult = await this.runWorkflowSteps(task, worktreePath, settings);
|
||||||
if (!workflowSuccess) {
|
if (!workflowResult.allPassed) {
|
||||||
// Move to in-review even when workflow steps fail so users can see the failure
|
// Check if revision was requested
|
||||||
|
if (workflowResult.revisionRequested) {
|
||||||
|
await this.handleWorkflowRevisionRequest(task, worktreePath, workflowResult.feedback, workflowResult.stepName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Hard failure - move to in-review so users can see the failure
|
||||||
await this.store.updateTask(task.id, { status: "failed", error: "Workflow step failed" });
|
await this.store.updateTask(task.id, { status: "failed", error: "Workflow step failed" });
|
||||||
await this.store.moveTask(task.id, "in-review");
|
await this.store.moveTask(task.id, "in-review");
|
||||||
executorLog.log(`✗ ${task.id} workflow step failed → in-review`);
|
executorLog.log(`✗ ${task.id} workflow step failed → in-review`);
|
||||||
@@ -1583,8 +1617,14 @@ export class TaskExecutor {
|
|||||||
executorLog.log(`${task.id}: captured ${modifiedFiles.length} modified files`);
|
executorLog.log(`${task.id}: captured ${modifiedFiles.length} modified files`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const workflowSuccess = await this.runWorkflowSteps(task, worktreePath, settings);
|
const workflowResult = await this.runWorkflowSteps(task, worktreePath, settings);
|
||||||
if (!workflowSuccess) {
|
if (!workflowResult.allPassed) {
|
||||||
|
// Check if revision was requested
|
||||||
|
if (workflowResult.revisionRequested) {
|
||||||
|
await this.handleWorkflowRevisionRequest(task, worktreePath, workflowResult.feedback, workflowResult.stepName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Hard failure
|
||||||
await this.store.updateTask(task.id, { status: "failed", error: "Workflow step failed" });
|
await this.store.updateTask(task.id, { status: "failed", error: "Workflow step failed" });
|
||||||
await this.store.moveTask(task.id, "in-review");
|
await this.store.moveTask(task.id, "in-review");
|
||||||
executorLog.log(`✗ ${task.id} workflow step failed on retry → in-review`);
|
executorLog.log(`✗ ${task.id} workflow step failed on retry → in-review`);
|
||||||
@@ -2264,6 +2304,133 @@ export class TaskExecutor {
|
|||||||
await this.store.logEntry(taskId, "Execution stopped — work discarded, moved to triage for re-specification");
|
await this.store.logEntry(taskId, "Execution stopped — work discarded, moved to triage for re-specification");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a workflow step revision request.
|
||||||
|
*
|
||||||
|
* This method:
|
||||||
|
* 1. Updates PROMPT.md with "Workflow Revision Instructions" section
|
||||||
|
* 2. Resets task execution state (all steps reset to pending)
|
||||||
|
* 3. Schedules fresh execution to run after current guard unwinds
|
||||||
|
*
|
||||||
|
* The task stays in "in-progress" and is scheduled for a fresh executor pass.
|
||||||
|
*/
|
||||||
|
private async handleWorkflowRevisionRequest(
|
||||||
|
task: Task,
|
||||||
|
worktreePath: string,
|
||||||
|
feedback: string,
|
||||||
|
stepName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
executorLog.log(`${task.id}: workflow revision requested by step "${stepName}"`);
|
||||||
|
await this.store.logEntry(
|
||||||
|
task.id,
|
||||||
|
`Workflow step "${stepName}" requested revision — resetting execution state`,
|
||||||
|
feedback,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Update PROMPT.md with revision instructions
|
||||||
|
await this.injectWorkflowRevisionInstructions(task, feedback);
|
||||||
|
|
||||||
|
// 2. Reset all steps to pending for fresh execution
|
||||||
|
const updatedTask = await this.store.getTask(task.id);
|
||||||
|
for (let i = 0; i < updatedTask.steps.length; i++) {
|
||||||
|
if (updatedTask.steps[i].status !== "pending") {
|
||||||
|
await this.store.updateStep(task.id, i, "pending");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Clear any session file so we get a fresh session
|
||||||
|
await this.store.updateTask(task.id, {
|
||||||
|
status: null,
|
||||||
|
sessionFile: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Schedule fresh execution after guard unwinds
|
||||||
|
// This prevents the race condition where the scheduler re-dispatches
|
||||||
|
// while the old execution guard is still set.
|
||||||
|
executorLog.log(`${task.id}: scheduling fresh execution after revision request`);
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
// Move task to todo briefly, then back to in-progress to trigger fresh execution
|
||||||
|
// The task is already in in-progress, so we need to:
|
||||||
|
// 1. Move to todo (this triggers the guard to clear)
|
||||||
|
// 2. Move back to in-progress (this triggers fresh execution)
|
||||||
|
await this.store.moveTask(task.id, "todo");
|
||||||
|
await this.store.moveTask(task.id, "in-progress");
|
||||||
|
executorLog.log(`${task.id}: revision rerun scheduled — moved to todo then in-progress`);
|
||||||
|
} catch (err: any) {
|
||||||
|
executorLog.error(`${task.id}: failed to schedule revision rerun: ${err.message}`);
|
||||||
|
// Fallback: log entry and let scheduler pick it up on next tick
|
||||||
|
await this.store.logEntry(
|
||||||
|
task.id,
|
||||||
|
"Workflow revision requested — executor ready for fresh execution",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject or update the "Workflow Revision Instructions" section in PROMPT.md.
|
||||||
|
* This section contains feedback from workflow steps that requested revisions.
|
||||||
|
* The section is replaced entirely to avoid accumulation of old feedback.
|
||||||
|
*/
|
||||||
|
private async injectWorkflowRevisionInstructions(task: Task, feedback: string): Promise<void> {
|
||||||
|
const promptPath = join(this.store.getFusionDir(), "tasks", task.id, "PROMPT.md");
|
||||||
|
|
||||||
|
// Read existing PROMPT.md
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = await readFile(promptPath, "utf-8");
|
||||||
|
} catch {
|
||||||
|
executorLog.warn(`${task.id}: PROMPT.md not found at ${promptPath}, skipping revision injection`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing Workflow Revision Instructions section
|
||||||
|
const revisionSectionHeader = "## Workflow Revision Instructions";
|
||||||
|
const revisionSectionContent = `${revisionSectionHeader}
|
||||||
|
|
||||||
|
The following feedback was received from quality gates and requires implementation changes:
|
||||||
|
|
||||||
|
${feedback}
|
||||||
|
|
||||||
|
**Important:** This is a revision request — address the feedback above by making the necessary code changes, then mark all affected steps as done and call task_done() when complete.
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
let newContent: string;
|
||||||
|
if (content.includes(revisionSectionHeader)) {
|
||||||
|
// Replace existing section
|
||||||
|
const sectionRegex = new RegExp(
|
||||||
|
`${revisionSectionHeader}[\\s\\S]*?(?=\\n## |\\n# |$)`,
|
||||||
|
"i"
|
||||||
|
);
|
||||||
|
if (sectionRegex.test(content)) {
|
||||||
|
newContent = content.replace(sectionRegex, revisionSectionContent);
|
||||||
|
} else {
|
||||||
|
// Fallback: append at end
|
||||||
|
newContent = content + "\n" + revisionSectionContent;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Append new section before any closing markers or at end
|
||||||
|
// Look for common markers like "## Acceptance Criteria" or just append
|
||||||
|
const acceptanceCriteriaMatch = content.match(/\n##\s+Acceptance Criteria\n/);
|
||||||
|
if (acceptanceCriteriaMatch) {
|
||||||
|
const insertIdx = acceptanceCriteriaMatch.index!;
|
||||||
|
newContent = content.slice(0, insertIdx) + "\n" + revisionSectionContent + content.slice(insertIdx);
|
||||||
|
} else {
|
||||||
|
newContent = content + "\n" + revisionSectionContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write updated content
|
||||||
|
try {
|
||||||
|
await writeFile(promptPath, newContent);
|
||||||
|
executorLog.log(`${task.id}: injected workflow revision instructions into PROMPT.md`);
|
||||||
|
} catch (err: any) {
|
||||||
|
executorLog.error(`${task.id}: failed to inject revision instructions: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture the list of files modified during agent execution.
|
* Capture the list of files modified during agent execution.
|
||||||
* Uses git diff against the stored baseCommitSha to determine what changed.
|
* Uses git diff against the stored baseCommitSha to determine what changed.
|
||||||
@@ -2332,16 +2499,16 @@ export class TaskExecutor {
|
|||||||
/**
|
/**
|
||||||
* Run workflow step agents sequentially after main task execution completes.
|
* Run workflow step agents sequentially after main task execution completes.
|
||||||
* Each workflow step spawns a separate agent with the step's prompt.
|
* Each workflow step spawns a separate agent with the step's prompt.
|
||||||
* Returns true if all steps pass, false if any fails.
|
* Returns structured result: all passed, all passed (true), failed (false), or revision requested.
|
||||||
*/
|
*/
|
||||||
private async runWorkflowSteps(
|
private async runWorkflowSteps(
|
||||||
task: Task,
|
task: Task,
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
): Promise<boolean> {
|
): Promise<WorkflowStepResult> {
|
||||||
// Check if task has enabled workflow steps
|
// Check if task has enabled workflow steps
|
||||||
const currentTask = await this.store.getTask(task.id);
|
const currentTask = await this.store.getTask(task.id);
|
||||||
if (!currentTask.enabledWorkflowSteps?.length) return true;
|
if (!currentTask.enabledWorkflowSteps?.length) return { allPassed: true };
|
||||||
|
|
||||||
const workflowStepIds = currentTask.enabledWorkflowSteps;
|
const workflowStepIds = currentTask.enabledWorkflowSteps;
|
||||||
const results: import("@fusion/core").WorkflowStepResult[] = [];
|
const results: import("@fusion/core").WorkflowStepResult[] = [];
|
||||||
@@ -2403,7 +2570,7 @@ export class TaskExecutor {
|
|||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = stepMode === "script"
|
const result: WorkflowStepOutcome = stepMode === "script"
|
||||||
? await this.executeScriptWorkflowStep(task, ws, worktreePath, settings)
|
? await this.executeScriptWorkflowStep(task, ws, worktreePath, settings)
|
||||||
: await this.executeWorkflowStep(task, ws, worktreePath, settings);
|
: await this.executeWorkflowStep(task, ws, worktreePath, settings);
|
||||||
const completedAt = new Date().toISOString();
|
const completedAt = new Date().toISOString();
|
||||||
@@ -2421,7 +2588,32 @@ export class TaskExecutor {
|
|||||||
completedAt,
|
completedAt,
|
||||||
});
|
});
|
||||||
await this.store.updateTask(task.id, { workflowStepResults: results });
|
await this.store.updateTask(task.id, { workflowStepResults: results });
|
||||||
|
} else if (result.revisionRequested) {
|
||||||
|
// Revision requested — this is a structured outcome that routes back to executor
|
||||||
|
await this.store.logEntry(
|
||||||
|
task.id,
|
||||||
|
`[pre-merge] Workflow step requested revision: ${ws.name}`,
|
||||||
|
result.output,
|
||||||
|
);
|
||||||
|
executorLog.log(`${task.id} — [pre-merge] workflow step requested revision: ${ws.name}`);
|
||||||
|
results.push({
|
||||||
|
workflowStepId: ws.id,
|
||||||
|
workflowStepName: ws.name,
|
||||||
|
phase: stepPhase,
|
||||||
|
status: "failed",
|
||||||
|
output: result.output || "Revision requested",
|
||||||
|
startedAt,
|
||||||
|
completedAt,
|
||||||
|
});
|
||||||
|
await this.store.updateTask(task.id, { workflowStepResults: results });
|
||||||
|
return {
|
||||||
|
allPassed: false,
|
||||||
|
revisionRequested: true,
|
||||||
|
feedback: result.output || "Workflow step requested revision",
|
||||||
|
stepName: ws.name,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
|
// Hard failure
|
||||||
await this.store.logEntry(
|
await this.store.logEntry(
|
||||||
task.id,
|
task.id,
|
||||||
`[pre-merge] Workflow step failed: ${ws.name}`,
|
`[pre-merge] Workflow step failed: ${ws.name}`,
|
||||||
@@ -2438,7 +2630,7 @@ export class TaskExecutor {
|
|||||||
completedAt,
|
completedAt,
|
||||||
});
|
});
|
||||||
await this.store.updateTask(task.id, { workflowStepResults: results });
|
await this.store.updateTask(task.id, { workflowStepResults: results });
|
||||||
return false;
|
return { allPassed: false, revisionRequested: false };
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const completedAt = new Date().toISOString();
|
const completedAt = new Date().toISOString();
|
||||||
@@ -2458,11 +2650,11 @@ export class TaskExecutor {
|
|||||||
completedAt,
|
completedAt,
|
||||||
});
|
});
|
||||||
await this.store.updateTask(task.id, { workflowStepResults: results });
|
await this.store.updateTask(task.id, { workflowStepResults: results });
|
||||||
return false;
|
return { allPassed: false, revisionRequested: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return { allPassed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2512,13 +2704,14 @@ export class TaskExecutor {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a single workflow step by spawning an agent with the step's prompt.
|
* Execute a single workflow step by spawning an agent with the step's prompt.
|
||||||
|
* Returns structured outcome with support for revision requests.
|
||||||
*/
|
*/
|
||||||
private async executeWorkflowStep(
|
private async executeWorkflowStep(
|
||||||
task: Task,
|
task: Task,
|
||||||
workflowStep: WorkflowStep,
|
workflowStep: WorkflowStep,
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
): Promise<{ success: boolean; output?: string; error?: string }> {
|
): Promise<WorkflowStepOutcome> {
|
||||||
const toolMode: "coding" | "readonly" = workflowStep.toolMode || "readonly";
|
const toolMode: "coding" | "readonly" = workflowStep.toolMode || "readonly";
|
||||||
const systemPrompt = `You are a workflow step agent executing: ${workflowStep.name}
|
const systemPrompt = `You are a workflow step agent executing: ${workflowStep.name}
|
||||||
|
|
||||||
@@ -2531,8 +2724,32 @@ Your Instructions:
|
|||||||
${workflowStep.prompt}
|
${workflowStep.prompt}
|
||||||
|
|
||||||
You have access to the file system to review changes.
|
You have access to the file system to review changes.
|
||||||
When your review is complete and everything looks good, simply state your findings.
|
|
||||||
If issues are found that need attention, describe them clearly.`;
|
## Feedback Format
|
||||||
|
|
||||||
|
When your review is complete, you MUST use one of these exact formats:
|
||||||
|
|
||||||
|
**For PASS (no issues found):**
|
||||||
|
Simply state your findings and approval. No special formatting required.
|
||||||
|
|
||||||
|
**For REVISION REQUESTED (issues found that require code changes):**
|
||||||
|
Your response MUST start with the exact phrase:
|
||||||
|
\`REQUEST REVISION\`
|
||||||
|
|
||||||
|
Followed by a clear, actionable description of what needs to be fixed.
|
||||||
|
Be specific: reference exact files, line numbers, or functions that need changes.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
\`REQUEST REVISION
|
||||||
|
|
||||||
|
The login function in src/auth.ts does not handle the case where the user
|
||||||
|
account is locked. Add proper error handling for the LOCKED_ACCOUNT error code
|
||||||
|
and show an appropriate message to the user.\`
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
- Only use "REQUEST REVISION" when the implementation needs code changes.
|
||||||
|
- If the code is correct and no changes are needed, just state your findings.
|
||||||
|
- Be constructive and actionable — vague feedback wastes the executor's time.`;
|
||||||
|
|
||||||
const agentLogger = new AgentLogger({
|
const agentLogger = new AgentLogger({
|
||||||
store: this.store,
|
store: this.store,
|
||||||
@@ -2599,6 +2816,20 @@ If issues are found that need attention, describe them clearly.`;
|
|||||||
session.dispose();
|
session.dispose();
|
||||||
await agentLogger.flush();
|
await agentLogger.flush();
|
||||||
|
|
||||||
|
// Check if the output contains a revision request
|
||||||
|
const trimmedOutput = output.trim();
|
||||||
|
const revisionMatch = trimmedOutput.match(/^REQUEST REVISION\s*\n*/i);
|
||||||
|
if (revisionMatch) {
|
||||||
|
// Extract the feedback after "REQUEST REVISION"
|
||||||
|
const feedbackStart = revisionMatch[0].length;
|
||||||
|
const feedback = trimmedOutput.slice(feedbackStart).trim();
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
revisionRequested: true,
|
||||||
|
output: feedback,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, output };
|
return { success: true, output };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
await agentLogger.flush();
|
await agentLogger.flush();
|
||||||
|
|||||||
Reference in New Issue
Block a user