feat(FN-4070): add workflow revision forking for task retries
Implements workflow revision forking with a new per-project setting, allowing follow-up tasks to inherit a fork of the calling task's workflow rather than the original revision. The feature adds classification logic, fork execution, and the settings UI, with docs and tests covering the full flow. Fusion-Task-Id: FN-4070
This commit is contained in:
5
.changeset/fn-4070-workflow-revision-fork.md
Normal file
5
.changeset/fn-4070-workflow-revision-fork.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fork workflow revision feedback that escapes a task's declared File Scope into dependent follow-up tasks instead of always appending it to the original task prompt.
|
||||||
@@ -208,6 +208,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
|
|||||||
| `autoResolveConflicts` | `boolean` | `true` | Enable automatic merge conflict resolution. |
|
| `autoResolveConflicts` | `boolean` | `true` | Enable automatic merge conflict resolution. |
|
||||||
| `smartConflictResolution` | `boolean` | `true` | Alias/preferred flag for smart conflict handling. |
|
| `smartConflictResolution` | `boolean` | `true` | Alias/preferred flag for smart conflict handling. |
|
||||||
| `mergerAutostashMaxAgeHours` | `number` | `24` | Maximum autostash age in hours before startup/periodic stale-stash sweep drops `fusion-merger-autostash:*` leftovers (minimum `1`). |
|
| `mergerAutostashMaxAgeHours` | `number` | `24` | Maximum autostash age in hours before startup/periodic stale-stash sweep drops `fusion-merger-autostash:*` leftovers (minimum `1`). |
|
||||||
|
| `workflowRevisionForkOnScopeMismatch` | `boolean` | `true` | When enabled, workflow revision feedback that explicitly names files outside the task's declared File Scope is forked into a dependent follow-up triage task instead of being appended to the original task's `PROMPT.md`. Set to `false` to keep the legacy append-and-rerun behavior. |
|
||||||
| `strictScopeEnforcement` | `boolean` | `false` | Block merges on out-of-scope file changes. |
|
| `strictScopeEnforcement` | `boolean` | `false` | Block merges on out-of-scope file changes. |
|
||||||
| `buildRetryCount` | `number` | `0` | Build retry attempts during merge. |
|
| `buildRetryCount` | `number` | `0` | Build retry attempts during merge. |
|
||||||
| `verificationFixRetries` | `number` | `3` | In-merge auto-fix retry attempts after deterministic test/build verification failures (0-3). |
|
| `verificationFixRetries` | `number` | `3` | In-merge auto-fix retry attempts after deterministic test/build verification failures (0-3). |
|
||||||
|
|||||||
@@ -104,10 +104,12 @@ handle the case where the user account is locked.
|
|||||||
|
|
||||||
When a revision is requested:
|
When a revision is requested:
|
||||||
|
|
||||||
1. The executor generates a **Workflow Revision Instructions** section in the task's `PROMPT.md`
|
1. Fusion scope-checks any explicit file paths named in the feedback against the task's declared `## File Scope`
|
||||||
2. All step statuses are reset to `pending` for a fresh execution pass
|
2. In-scope feedback is appended to a **Workflow Revision Instructions** section in the task's `PROMPT.md`
|
||||||
3. The task remains in `in-progress` and a fresh executor session is scheduled
|
3. Explicitly out-of-scope feedback is forked into a dependent follow-up triage task instead of mutating the original task branch
|
||||||
4. The agent receives the revision feedback at the start of its next session
|
4. If both kinds are present, Fusion splits the feedback: the original task reruns only with the retained in-scope block while the follow-up captures the unrelated work
|
||||||
|
5. If no in-scope feedback remains after splitting, the original task is left untouched and continues its normal completion path while only the follow-up task is created
|
||||||
|
6. When the original task retains in-scope feedback, only the last implementation step is reopened and a fresh executor session is scheduled
|
||||||
|
|
||||||
### Feedback Format
|
### Feedback Format
|
||||||
|
|
||||||
@@ -121,6 +123,8 @@ REQUEST REVISION
|
|||||||
|
|
||||||
The revision block replaces any prior revision instructions (no accumulation).
|
The revision block replaces any prior revision instructions (no accumulation).
|
||||||
|
|
||||||
|
By default this split-and-fork behavior is enabled through the project setting `workflowRevisionForkOnScopeMismatch`. Set it to `false` to restore the legacy behavior that appends all workflow revision feedback to the original task even when it references files outside the declared File Scope.
|
||||||
|
|
||||||
### Hard Failures vs Revisions
|
### Hard Failures vs Revisions
|
||||||
|
|
||||||
Not all workflow failures are revision requests:
|
Not all workflow failures are revision requests:
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
|||||||
worktreeRebaseLocalBase: true,
|
worktreeRebaseLocalBase: true,
|
||||||
mergeConflictStrategy: "smart-prefer-main",
|
mergeConflictStrategy: "smart-prefer-main",
|
||||||
workflowStepTimeoutMs: 360_000,
|
workflowStepTimeoutMs: 360_000,
|
||||||
|
workflowRevisionForkOnScopeMismatch: true,
|
||||||
strictScopeEnforcement: false,
|
strictScopeEnforcement: false,
|
||||||
buildRetryCount: 0,
|
buildRetryCount: 0,
|
||||||
verificationFixRetries: 3,
|
verificationFixRetries: 3,
|
||||||
|
|||||||
@@ -2173,6 +2173,11 @@ export interface ProjectSettings {
|
|||||||
* given one shot to retry with the configured fallback model before the
|
* given one shot to retry with the configured fallback model before the
|
||||||
* step is reported as failed. Default: 360_000 (6 minutes). */
|
* step is reported as failed. Default: 360_000 (6 minutes). */
|
||||||
workflowStepTimeoutMs?: number;
|
workflowStepTimeoutMs?: number;
|
||||||
|
/** When true (default), workflow revision feedback that explicitly names files
|
||||||
|
* outside the task's declared File Scope is forked into a dependent follow-up
|
||||||
|
* task instead of being appended to the original PROMPT.md. Set to false to
|
||||||
|
* preserve the legacy append-and-rerun behavior. */
|
||||||
|
workflowRevisionForkOnScopeMismatch?: boolean;
|
||||||
/** When true, out-of-scope file changes block merge instead of just logging warnings.
|
/** When true, out-of-scope file changes block merge instead of just logging warnings.
|
||||||
* Useful for teams that want strict enforcement of declared File Scope.
|
* Useful for teams that want strict enforcement of declared File Scope.
|
||||||
* Default: false (soft guardrail — warnings only). */
|
* Default: false (soft guardrail — warnings only). */
|
||||||
|
|||||||
@@ -3580,6 +3580,25 @@ export function SettingsModal({
|
|||||||
<small>When enabled, tasks that pass review are automatically merged into the main branch</small>
|
<small>When enabled, tasks that pass review are automatically merged into the main branch</small>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="workflowRevisionForkOnScopeMismatch" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="workflowRevisionForkOnScopeMismatch"
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.workflowRevisionForkOnScopeMismatch !== false}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, workflowRevisionForkOnScopeMismatch: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Fork scope-mismatched workflow revisions into follow-up tasks
|
||||||
|
</label>
|
||||||
|
<details className="settings-option-details">
|
||||||
|
<summary>More details</summary>
|
||||||
|
<small>
|
||||||
|
When enabled, workflow revision feedback that explicitly names files outside the original task's declared File Scope is split into a dependent follow-up task instead of being appended to the current task's PROMPT.md.
|
||||||
|
</small>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="verificationFixRetries">Verification auto-fix retries</label>
|
<label htmlFor="verificationFixRetries">Verification auto-fix retries</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ const defaultSettings = {
|
|||||||
pushAfterMerge: false,
|
pushAfterMerge: false,
|
||||||
pushRemote: "origin",
|
pushRemote: "origin",
|
||||||
verificationFixRetries: 2,
|
verificationFixRetries: 2,
|
||||||
|
workflowRevisionForkOnScopeMismatch: true,
|
||||||
recycleWorktrees: false,
|
recycleWorktrees: false,
|
||||||
worktreeNaming: "random",
|
worktreeNaming: "random",
|
||||||
includeTaskIdInCommit: true,
|
includeTaskIdInCommit: true,
|
||||||
@@ -1967,6 +1968,34 @@ describe("SettingsModal", () => {
|
|||||||
expect(screen.getByText(/When enabled, tasks that pass review are automatically merged/i)).toBeVisible();
|
expect(screen.getByText(/When enabled, tasks that pass review are automatically merged/i)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads workflow revision fork checkbox from project settings", async () => {
|
||||||
|
renderModal({ initialSection: "merge" });
|
||||||
|
await waitForSettingsModalReady();
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole("checkbox", {
|
||||||
|
name: /fork scope-mismatched workflow revisions into follow-up tasks/i,
|
||||||
|
});
|
||||||
|
expect(checkbox).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves workflow revision fork checkbox changes", async () => {
|
||||||
|
renderModal({ initialSection: "merge" });
|
||||||
|
await waitForSettingsModalReady();
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole("checkbox", {
|
||||||
|
name: /fork scope-mismatched workflow revisions into follow-up tasks/i,
|
||||||
|
});
|
||||||
|
await userEvent.click(checkbox);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
||||||
|
expect(payload.workflowRevisionForkOnScopeMismatch).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows Push Remote input when push-after-merge is enabled", async () => {
|
it("shows Push Remote input when push-after-merge is enabled", async () => {
|
||||||
renderModal();
|
renderModal();
|
||||||
|
|
||||||
|
|||||||
@@ -2137,6 +2137,7 @@ describe("Workflow Steps Execution", () => {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
store.parseFileScopeFromPrompt.mockResolvedValue(["src/auth.ts"]);
|
||||||
|
|
||||||
// First call: main agent with fn_task_done
|
// First call: main agent with fn_task_done
|
||||||
// Second call: workflow step agent that returns REQUEST REVISION
|
// Second call: workflow step agent that returns REQUEST REVISION
|
||||||
@@ -2206,7 +2207,9 @@ describe("Workflow Steps Execution", () => {
|
|||||||
"FN-001",
|
"FN-001",
|
||||||
expect.stringContaining("requested revision"),
|
expect.stringContaining("requested revision"),
|
||||||
expect.stringContaining("SQL injection"),
|
expect.stringContaining("SQL injection"),
|
||||||
|
expect.objectContaining({ agentId: "executor" }),
|
||||||
);
|
);
|
||||||
|
expect(store.createTask).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// Workflow step result should be marked as failed
|
// Workflow step result should be marked as failed
|
||||||
expect(store.updateTask).toHaveBeenCalledWith(
|
expect(store.updateTask).toHaveBeenCalledWith(
|
||||||
@@ -2229,6 +2232,294 @@ describe("Workflow Steps Execution", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forks out-of-scope workflow revision feedback into a follow-up task and leaves the original task untouched", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
|
||||||
|
store.getTask.mockResolvedValue({
|
||||||
|
id: "FN-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test task",
|
||||||
|
column: "in-progress",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [{ name: "Implement", status: "done" }],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
enabledWorkflowSteps: ["WS-001"],
|
||||||
|
prompt: "# test\n## File Scope\n- `packages/engine/src/executor.ts`\n## Steps\n### Step 0: Implement\n- [x] check",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
store.parseFileScopeFromPrompt.mockResolvedValue(["packages/engine/src/executor.ts"]);
|
||||||
|
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let callIdx = 0;
|
||||||
|
let subscribeHandler: any;
|
||||||
|
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||||||
|
callIdx++;
|
||||||
|
if (callIdx === 1) {
|
||||||
|
const customTools = opts.customTools || [];
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
const taskDoneTool = customTools.find((t: any) => t.name === "fn_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: {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
subscribeHandler?.({
|
||||||
|
type: "message_update",
|
||||||
|
assistantMessageEvent: { type: "text_delta", delta: "REQUEST REVISION\n\nFix `packages/core/src/types.ts` before merge." },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
subscribe: vi.fn((handler: any) => {
|
||||||
|
subscribeHandler = handler;
|
||||||
|
}),
|
||||||
|
state: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}) 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: "Implement", status: "done" }],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
enabledWorkflowSteps: ["WS-001"],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
title: expect.stringContaining("workflow follow-up"),
|
||||||
|
dependencies: ["FN-001"],
|
||||||
|
source: expect.objectContaining({
|
||||||
|
sourceType: "workflow_step",
|
||||||
|
sourceParentTaskId: "FN-001",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({ status: null, sessionFile: null }));
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||||||
|
expect(onComplete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits mixed workflow revision feedback between the original task and a follow-up task", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
|
||||||
|
store.getTask.mockResolvedValue({
|
||||||
|
id: "FN-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test task",
|
||||||
|
column: "in-progress",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [{ name: "Implement", status: "done" }],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
enabledWorkflowSteps: ["WS-001"],
|
||||||
|
prompt: "# test\n## File Scope\n- `packages/engine/src/executor.ts`\n## Steps\n### Step 0: Implement\n- [x] check",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
store.parseFileScopeFromPrompt.mockResolvedValue(["packages/engine/src/executor.ts"]);
|
||||||
|
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let callIdx = 0;
|
||||||
|
let subscribeHandler: any;
|
||||||
|
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||||||
|
callIdx++;
|
||||||
|
if (callIdx === 1) {
|
||||||
|
const customTools = opts.customTools || [];
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
const taskDoneTool = customTools.find((t: any) => t.name === "fn_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: {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
subscribeHandler?.({
|
||||||
|
type: "message_update",
|
||||||
|
assistantMessageEvent: {
|
||||||
|
type: "text_delta",
|
||||||
|
delta: "REQUEST REVISION\n\nTighten `packages/engine/src/executor.ts`.\n\nAdd the setting in `packages/core/src/settings-schema.ts`.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
subscribe: vi.fn((handler: any) => {
|
||||||
|
subscribeHandler = handler;
|
||||||
|
}),
|
||||||
|
state: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}) as any);
|
||||||
|
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
|
|
||||||
|
await executor.execute({
|
||||||
|
id: "FN-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test task",
|
||||||
|
column: "in-progress",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [{ name: "Implement", status: "done" }],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
enabledWorkflowSteps: ["WS-001"],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.createTask).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
|
"FN-001",
|
||||||
|
expect.stringContaining("split feedback"),
|
||||||
|
expect.stringContaining("packages/engine/src/executor.ts"),
|
||||||
|
expect.objectContaining({ agentId: "executor" }),
|
||||||
|
);
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ status: null, sessionFile: null }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves legacy append-and-rerun behavior when workflow revision forking is disabled", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
store.getSettings.mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: false,
|
||||||
|
workflowRevisionForkOnScopeMismatch: false,
|
||||||
|
worktreeInitCommand: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
store.getTask.mockResolvedValue({
|
||||||
|
id: "FN-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test task",
|
||||||
|
column: "in-progress",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [{ name: "Implement", status: "done" }],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
enabledWorkflowSteps: ["WS-001"],
|
||||||
|
prompt: "# test\n## File Scope\n- `packages/engine/src/executor.ts`\n## Steps\n### Step 0: Implement\n- [x] check",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
store.parseFileScopeFromPrompt.mockResolvedValue(["packages/engine/src/executor.ts"]);
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let callIdx = 0;
|
||||||
|
let subscribeHandler: any;
|
||||||
|
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||||||
|
callIdx++;
|
||||||
|
if (callIdx === 1) {
|
||||||
|
const customTools = opts.customTools || [];
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
const taskDoneTool = customTools.find((t: any) => t.name === "fn_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: {
|
||||||
|
prompt: vi.fn().mockImplementation(async () => {
|
||||||
|
subscribeHandler?.({
|
||||||
|
type: "message_update",
|
||||||
|
assistantMessageEvent: { type: "text_delta", delta: "REQUEST REVISION\n\nFix `packages/core/src/types.ts` before merge." },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
subscribe: vi.fn((handler: any) => {
|
||||||
|
subscribeHandler = handler;
|
||||||
|
}),
|
||||||
|
state: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}) as any);
|
||||||
|
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
|
|
||||||
|
await executor.execute({
|
||||||
|
id: "FN-001",
|
||||||
|
title: "Test",
|
||||||
|
description: "Test task",
|
||||||
|
column: "in-progress",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [{ name: "Implement", status: "done" }],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
enabledWorkflowSteps: ["WS-001"],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.createTask).not.toHaveBeenCalled();
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ status: null, sessionFile: null }));
|
||||||
|
});
|
||||||
|
|
||||||
it("passing workflow step moves task to in-review normally", async () => {
|
it("passing workflow step moves task to in-review normally", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
|
|
||||||
|
|||||||
@@ -271,9 +271,22 @@ export function createMockStore() {
|
|||||||
updateTask: vi.fn().mockResolvedValue({}),
|
updateTask: vi.fn().mockResolvedValue({}),
|
||||||
moveTask: vi.fn().mockResolvedValue({}),
|
moveTask: vi.fn().mockResolvedValue({}),
|
||||||
mergeTask: vi.fn().mockResolvedValue({}),
|
mergeTask: vi.fn().mockResolvedValue({}),
|
||||||
|
createTask: vi.fn().mockImplementation(async (input: Record<string, unknown>) => ({
|
||||||
|
id: "FN-002",
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
column: "triage",
|
||||||
|
dependencies: input.dependencies ?? [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})),
|
||||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
addTaskComment: vi.fn().mockResolvedValue(undefined),
|
addTaskComment: vi.fn().mockResolvedValue(undefined),
|
||||||
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
|
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
|
||||||
|
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||||||
updateSettings: vi.fn().mockResolvedValue({}),
|
updateSettings: vi.fn().mockResolvedValue({}),
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
extractReferencedPathsFromWorkflowFeedback,
|
||||||
|
partitionWorkflowRevisionFeedback,
|
||||||
|
workflowPathMatchesDeclaredScope,
|
||||||
|
} from "../executor.js";
|
||||||
|
|
||||||
|
describe("workflow revision scope partitioning", () => {
|
||||||
|
const declaredScope = [
|
||||||
|
"packages/engine/src/executor.ts",
|
||||||
|
"packages/engine/src/__tests__/*",
|
||||||
|
];
|
||||||
|
|
||||||
|
it("keeps fully in-scope feedback attached to the original task", () => {
|
||||||
|
const feedback = [
|
||||||
|
"Update `packages/engine/src/executor.ts` to guard the rerun path.",
|
||||||
|
"Add a regression in `packages/engine/src/__tests__/executor-step-session.test.ts`.",
|
||||||
|
].join("\n\n");
|
||||||
|
|
||||||
|
const result = partitionWorkflowRevisionFeedback(feedback, declaredScope);
|
||||||
|
|
||||||
|
expect(result.inScopeFeedback).toBe(feedback);
|
||||||
|
expect(result.outOfScopeFeedback).toBe("");
|
||||||
|
expect(result.outOfScopeSegments).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forks fully out-of-scope feedback into a follow-up block", () => {
|
||||||
|
const feedback = [
|
||||||
|
"Move the setting into `packages/core/src/types.ts`.",
|
||||||
|
"Document it in `docs/settings-reference.md`.",
|
||||||
|
].join("\n\n");
|
||||||
|
|
||||||
|
const result = partitionWorkflowRevisionFeedback(feedback, declaredScope);
|
||||||
|
|
||||||
|
expect(result.inScopeFeedback).toBe("");
|
||||||
|
expect(result.outOfScopeFeedback).toBe(feedback);
|
||||||
|
expect(result.outOfScopeSegments).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits mixed feedback by paragraph and preserves pathless guidance with the original task", () => {
|
||||||
|
const feedback = [
|
||||||
|
"Tighten the executor behavior in `packages/engine/src/executor.ts`.",
|
||||||
|
"Keep the rerun log message concise.",
|
||||||
|
"Add the new opt-out setting in `packages/core/src/settings-schema.ts`.",
|
||||||
|
].join("\n\n");
|
||||||
|
|
||||||
|
const result = partitionWorkflowRevisionFeedback(feedback, declaredScope);
|
||||||
|
|
||||||
|
expect(result.inScopeSegments).toEqual([
|
||||||
|
"Tighten the executor behavior in `packages/engine/src/executor.ts`.",
|
||||||
|
"Keep the rerun log message concise.",
|
||||||
|
]);
|
||||||
|
expect(result.outOfScopeSegments).toEqual([
|
||||||
|
"Add the new opt-out setting in `packages/core/src/settings-schema.ts`.",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps feedback with no detectable file paths on the original task", () => {
|
||||||
|
const feedback = "Clarify the retry behavior and keep the reviewer-facing explanation actionable.";
|
||||||
|
|
||||||
|
const result = partitionWorkflowRevisionFeedback(feedback, declaredScope);
|
||||||
|
|
||||||
|
expect(result.detectedPaths).toEqual([]);
|
||||||
|
expect(result.inScopeFeedback).toBe(feedback);
|
||||||
|
expect(result.outOfScopeFeedback).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes feedback paths before comparing them to declared scope", () => {
|
||||||
|
expect(workflowPathMatchesDeclaredScope("./packages/engine/src/__tests__/executor-step-session.test.ts", declaredScope)).toBe(true);
|
||||||
|
expect(workflowPathMatchesDeclaredScope("packages/core/src/types.ts", declaredScope)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts backticked and bare project-relative paths", () => {
|
||||||
|
const feedback = "Touch `packages/engine/src/executor.ts`, then mirror the test in packages/engine/src/__tests__/executor-step-session.test.ts.";
|
||||||
|
|
||||||
|
expect(extractReferencedPathsFromWorkflowFeedback(feedback)).toEqual([
|
||||||
|
"packages/engine/src/executor.ts",
|
||||||
|
"packages/engine/src/__tests__/executor-step-session.test.ts",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -147,7 +147,116 @@ export function determineRevisionResetStart(
|
|||||||
}
|
}
|
||||||
return firstCandidate;
|
return firstCandidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkflowRevisionFeedbackPartition {
|
||||||
|
inScopeFeedback: string;
|
||||||
|
outOfScopeFeedback: string;
|
||||||
|
inScopeSegments: string[];
|
||||||
|
outOfScopeSegments: string[];
|
||||||
|
detectedPaths: string[];
|
||||||
|
}
|
||||||
|
|
||||||
const WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS = 4_000;
|
const WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS = 4_000;
|
||||||
|
const WORKFLOW_FEEDBACK_PATH_REGEX = /`([^`\n]+)`|(?<![A-Za-z0-9_.-])((?:\.\.?\/)?(?:@?[A-Za-z0-9._-]+\/)+[A-Za-z0-9._-]+(?:\.[A-Za-z0-9._-]+)?)/g;
|
||||||
|
|
||||||
|
function normalizeWorkflowScopePath(pathValue: string): string {
|
||||||
|
return pathValue
|
||||||
|
.trim()
|
||||||
|
.replace(/\\/g, "/")
|
||||||
|
.replace(/^\.\//, "")
|
||||||
|
.replace(/\/+/g, "/")
|
||||||
|
.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTrailingPathPunctuation(pathValue: string): string {
|
||||||
|
return pathValue.replace(/[),.:;!?]+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractReferencedPathsFromWorkflowFeedback(feedback: string): string[] {
|
||||||
|
const extracted: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const match of feedback.matchAll(WORKFLOW_FEEDBACK_PATH_REGEX)) {
|
||||||
|
const candidate = stripTrailingPathPunctuation(match[1] ?? match[2] ?? "");
|
||||||
|
const normalized = normalizeWorkflowScopePath(candidate);
|
||||||
|
if (!normalized.includes("/") || !normalized) continue;
|
||||||
|
if (seen.has(normalized)) continue;
|
||||||
|
seen.add(normalized);
|
||||||
|
extracted.push(normalized);
|
||||||
|
}
|
||||||
|
return extracted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workflowPathMatchesDeclaredScope(filePath: string, scopePatterns: readonly string[]): boolean {
|
||||||
|
const normalizedPath = normalizeWorkflowScopePath(filePath);
|
||||||
|
for (const rawPattern of scopePatterns) {
|
||||||
|
const pattern = normalizeWorkflowScopePath(rawPattern);
|
||||||
|
if (!pattern) continue;
|
||||||
|
if (/\/\*+$/.test(pattern)) {
|
||||||
|
const directory = pattern.replace(/\/\*+$/, "");
|
||||||
|
if (normalizedPath === directory || normalizedPath.startsWith(`${directory}/`)) return true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pattern.endsWith("/")) {
|
||||||
|
if (normalizedPath.startsWith(pattern)) return true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (normalizedPath === pattern) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partitionWorkflowRevisionFeedback(
|
||||||
|
feedback: string,
|
||||||
|
declaredFileScope: readonly string[],
|
||||||
|
): WorkflowRevisionFeedbackPartition {
|
||||||
|
const trimmedFeedback = feedback.trim();
|
||||||
|
if (!trimmedFeedback || declaredFileScope.length === 0) {
|
||||||
|
return {
|
||||||
|
inScopeFeedback: trimmedFeedback,
|
||||||
|
outOfScopeFeedback: "",
|
||||||
|
inScopeSegments: trimmedFeedback ? [trimmedFeedback] : [],
|
||||||
|
outOfScopeSegments: [],
|
||||||
|
detectedPaths: extractReferencedPathsFromWorkflowFeedback(trimmedFeedback),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = trimmedFeedback.split(/\n\s*\n/).map((segment) => segment.trim()).filter(Boolean);
|
||||||
|
const allDetectedPaths = extractReferencedPathsFromWorkflowFeedback(trimmedFeedback);
|
||||||
|
if (allDetectedPaths.length === 0) {
|
||||||
|
return {
|
||||||
|
inScopeFeedback: trimmedFeedback,
|
||||||
|
outOfScopeFeedback: "",
|
||||||
|
inScopeSegments: trimmedFeedback ? [trimmedFeedback] : [],
|
||||||
|
outOfScopeSegments: [],
|
||||||
|
detectedPaths: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const inScopeSegments: string[] = [];
|
||||||
|
const outOfScopeSegments: string[] = [];
|
||||||
|
for (const segment of segments) {
|
||||||
|
const segmentPaths = extractReferencedPathsFromWorkflowFeedback(segment);
|
||||||
|
if (segmentPaths.length === 0) {
|
||||||
|
inScopeSegments.push(segment);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasOutOfScopePath = segmentPaths.some((path) => !workflowPathMatchesDeclaredScope(path, declaredFileScope));
|
||||||
|
if (hasOutOfScopePath) {
|
||||||
|
outOfScopeSegments.push(segment);
|
||||||
|
} else {
|
||||||
|
inScopeSegments.push(segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
inScopeFeedback: inScopeSegments.join("\n\n"),
|
||||||
|
outOfScopeFeedback: outOfScopeSegments.join("\n\n"),
|
||||||
|
inScopeSegments,
|
||||||
|
outOfScopeSegments,
|
||||||
|
detectedPaths: allDetectedPaths,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class NonRetryableWorktreeError extends Error {}
|
class NonRetryableWorktreeError extends Error {}
|
||||||
|
|
||||||
@@ -2898,17 +3007,20 @@ export class TaskExecutor {
|
|||||||
if (!workflowResult.allPassed) {
|
if (!workflowResult.allPassed) {
|
||||||
// Check if revision was requested
|
// Check if revision was requested
|
||||||
if (workflowResult.revisionRequested) {
|
if (workflowResult.revisionRequested) {
|
||||||
await this.handleWorkflowRevisionRequest(task, worktreePath, workflowResult.feedback, workflowResult.stepName);
|
const rerunScheduled = await this.handleWorkflowRevisionRequest(task, worktreePath, workflowResult.feedback, workflowResult.stepName, settings);
|
||||||
|
if (rerunScheduled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try to fix workflow step failures with retries
|
||||||
|
const retried = await this.handleWorkflowStepFailure(task, worktreePath, workflowResult.feedback, workflowResult.stepName || "Unknown");
|
||||||
|
if (retried) {
|
||||||
|
return; // Retry scheduled
|
||||||
|
}
|
||||||
|
// Retries exhausted - send back to in-progress for remediation
|
||||||
|
await this.sendTaskBackForFix(task, worktreePath, workflowResult.feedback, workflowResult.stepName || "Unknown", "Workflow step failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Try to fix workflow step failures with retries
|
|
||||||
const retried = await this.handleWorkflowStepFailure(task, worktreePath, workflowResult.feedback, workflowResult.stepName || "Unknown");
|
|
||||||
if (retried) {
|
|
||||||
return; // Retry scheduled
|
|
||||||
}
|
|
||||||
// Retries exhausted - send back to in-progress for remediation
|
|
||||||
await this.sendTaskBackForFix(task, worktreePath, workflowResult.feedback, workflowResult.stepName || "Unknown", "Workflow step failed");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
executorLog.log(`${task.id}: fast mode — skipping pre-merge workflow steps`);
|
executorLog.log(`${task.id}: fast mode — skipping pre-merge workflow steps`);
|
||||||
@@ -3534,17 +3646,20 @@ export class TaskExecutor {
|
|||||||
if (!workflowResult.allPassed) {
|
if (!workflowResult.allPassed) {
|
||||||
// Check if revision was requested
|
// Check if revision was requested
|
||||||
if (workflowResult.revisionRequested) {
|
if (workflowResult.revisionRequested) {
|
||||||
await this.handleWorkflowRevisionRequest(task, worktreePath, workflowResult.feedback, workflowResult.stepName);
|
const rerunScheduled = await this.handleWorkflowRevisionRequest(task, worktreePath, workflowResult.feedback, workflowResult.stepName, settings);
|
||||||
|
if (rerunScheduled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try to fix workflow step failures with retries
|
||||||
|
const retried = await this.handleWorkflowStepFailure(task, worktreePath, workflowResult.feedback, workflowResult.stepName || "Unknown");
|
||||||
|
if (retried) {
|
||||||
|
return; // Retry scheduled
|
||||||
|
}
|
||||||
|
// Retries exhausted - send back to in-progress for remediation
|
||||||
|
await this.sendTaskBackForFix(task, worktreePath, workflowResult.feedback, workflowResult.stepName || "Unknown", "Workflow step failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Try to fix workflow step failures with retries
|
|
||||||
const retried = await this.handleWorkflowStepFailure(task, worktreePath, workflowResult.feedback, workflowResult.stepName || "Unknown");
|
|
||||||
if (retried) {
|
|
||||||
return; // Retry scheduled
|
|
||||||
}
|
|
||||||
// Retries exhausted - send back to in-progress for remediation
|
|
||||||
await this.sendTaskBackForFix(task, worktreePath, workflowResult.feedback, workflowResult.stepName || "Unknown", "Workflow step failed");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
executorLog.log(`${task.id}: fast mode — skipping pre-merge workflow steps`);
|
executorLog.log(`${task.id}: fast mode — skipping pre-merge workflow steps`);
|
||||||
@@ -3723,11 +3838,14 @@ export class TaskExecutor {
|
|||||||
}
|
}
|
||||||
if (!workflowResult.allPassed) {
|
if (!workflowResult.allPassed) {
|
||||||
if (workflowResult.revisionRequested) {
|
if (workflowResult.revisionRequested) {
|
||||||
await this.handleWorkflowRevisionRequest(task, worktreePath, workflowResult.feedback, workflowResult.stepName);
|
const rerunScheduled = await this.handleWorkflowRevisionRequest(task, worktreePath, workflowResult.feedback, workflowResult.stepName, settings);
|
||||||
|
if (rerunScheduled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.sendTaskBackForFix(task, worktreePath, workflowResult.feedback, workflowResult.stepName || "Unknown", "Workflow step failed on retry");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.sendTaskBackForFix(task, worktreePath, workflowResult.feedback, workflowResult.stepName || "Unknown", "Workflow step failed on retry");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
executorLog.log(`${task.id}: fast mode — skipping pre-merge workflow steps`);
|
executorLog.log(`${task.id}: fast mode — skipping pre-merge workflow steps`);
|
||||||
@@ -4774,38 +4892,99 @@ export class TaskExecutor {
|
|||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
feedback: string,
|
feedback: string,
|
||||||
stepName: string,
|
stepName: string,
|
||||||
): Promise<void> {
|
settings: Settings,
|
||||||
|
): Promise<boolean> {
|
||||||
executorLog.log(`${task.id}: workflow revision requested by step "${stepName}"`);
|
executorLog.log(`${task.id}: workflow revision requested by step "${stepName}"`);
|
||||||
this.clearCompletedTaskWatchdog(task.id);
|
this.clearCompletedTaskWatchdog(task.id);
|
||||||
|
|
||||||
|
const shouldForkOnScopeMismatch = settings.workflowRevisionForkOnScopeMismatch !== false;
|
||||||
|
let inScopeFeedback = feedback.trim();
|
||||||
|
let outOfScopeFeedback = "";
|
||||||
|
let followUpTaskId: string | undefined;
|
||||||
|
|
||||||
|
if (shouldForkOnScopeMismatch) {
|
||||||
|
const declaredFileScope = await this.store.parseFileScopeFromPrompt(task.id).catch(() => [] as string[]);
|
||||||
|
const partition = partitionWorkflowRevisionFeedback(feedback, declaredFileScope);
|
||||||
|
inScopeFeedback = partition.inScopeFeedback;
|
||||||
|
outOfScopeFeedback = partition.outOfScopeFeedback;
|
||||||
|
|
||||||
|
if (outOfScopeFeedback) {
|
||||||
|
const followUpTask = await this.createWorkflowRevisionFollowUpTask(task, stepName, outOfScopeFeedback);
|
||||||
|
followUpTaskId = followUpTask.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inScopeFeedback) {
|
||||||
|
await this.store.logEntry(
|
||||||
|
task.id,
|
||||||
|
followUpTaskId
|
||||||
|
? `Workflow step "${stepName}" requested revision — feedback forked to follow-up ${followUpTaskId}; original task left unchanged`
|
||||||
|
: `Workflow step "${stepName}" requested revision — no in-scope feedback detected`,
|
||||||
|
outOfScopeFeedback || feedback,
|
||||||
|
this.currentRunContext,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const updatedTask = await this.store.getTask(task.id);
|
const updatedTask = await this.store.getTask(task.id);
|
||||||
const reopen = await this.reopenLastStepForRevision(task.id, updatedTask);
|
const reopen = await this.reopenLastStepForRevision(task.id, updatedTask);
|
||||||
const reopenSummary = reopen
|
const reopenSummary = reopen
|
||||||
? `re-opening Step ${reopen.index + 1} ("${reopen.name}") for in-place fix`
|
? `re-opening Step ${reopen.index + 1} ("${reopen.name}") for in-place fix`
|
||||||
: "no step to re-open (none were completed)";
|
: "no step to re-open (none were completed)";
|
||||||
|
|
||||||
await this.store.logEntry(
|
const logMessage = followUpTaskId
|
||||||
task.id,
|
? `Workflow step "${stepName}" requested revision — split feedback: appended in-scope guidance and forked out-of-scope work to ${followUpTaskId}; ${reopenSummary}`
|
||||||
`Workflow step "${stepName}" requested revision — ${reopenSummary}`,
|
: `Workflow step "${stepName}" requested revision — feedback appended to original task; ${reopenSummary}`;
|
||||||
feedback,
|
await this.store.logEntry(task.id, logMessage, inScopeFeedback, this.currentRunContext);
|
||||||
);
|
|
||||||
|
|
||||||
await this.injectWorkflowRevisionInstructions(task, feedback);
|
await this.injectWorkflowRevisionInstructions(task, inScopeFeedback);
|
||||||
|
|
||||||
await this.store.updateTask(task.id, {
|
await this.store.updateTask(task.id, {
|
||||||
status: null,
|
status: null,
|
||||||
sessionFile: 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`);
|
executorLog.log(`${task.id}: scheduling fresh execution after revision request`);
|
||||||
this.scheduleWorkflowRerun(
|
this.scheduleWorkflowRerun(
|
||||||
task.id,
|
task.id,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
`${task.id}: revision rerun scheduled — moved to todo then in-progress`,
|
`${task.id}: revision rerun scheduled — moved to todo then in-progress`,
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createWorkflowRevisionFollowUpTask(
|
||||||
|
task: Task,
|
||||||
|
stepName: string,
|
||||||
|
feedback: string,
|
||||||
|
): Promise<Task> {
|
||||||
|
const title = `${task.id}: workflow follow-up from ${stepName}`;
|
||||||
|
const description = [
|
||||||
|
`Follow-up work forked from workflow revision feedback on ${task.id}.`,
|
||||||
|
"",
|
||||||
|
`Original task: ${task.id}${task.title ? ` — ${task.title}` : ""}`,
|
||||||
|
`Workflow step: ${stepName}`,
|
||||||
|
"",
|
||||||
|
"This feedback referenced files outside the original task's declared File Scope, so it was forked into a follow-up task instead of mutating the original PROMPT.md.",
|
||||||
|
"",
|
||||||
|
"## Out-of-Scope Workflow Revision Feedback",
|
||||||
|
"",
|
||||||
|
feedback,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return this.store.createTask({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
dependencies: [task.id],
|
||||||
|
source: {
|
||||||
|
sourceType: "workflow_step",
|
||||||
|
sourceParentTaskId: task.id,
|
||||||
|
sourceMetadata: {
|
||||||
|
workflowStepName: stepName,
|
||||||
|
routing: "scope-mismatch-fork",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user