fix(KB-111): implement invalid transition handling in executor

- Detect and handle invalid column transitions during task step updates
- Reorder error checks to catch invalid transitions before paused status
- Improve log messages with actual column names for better debugging
- Add comprehensive tests for invalid transition error scenarios
- Include changeset for the invalid transition fix
This commit is contained in:
gsxdsm
2026-03-30 07:40:41 -07:00
parent 863395b43c
commit 2de1f8c419
3 changed files with 117 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@dustinbyrne/kb": patch
---
Fix executor to handle "Invalid transition" errors gracefully instead of marking tasks as failed. When a task is moved by the user while the executor is running, the executor's attempt to move the task will fail with an invalid transition error. This no longer marks the task as failed - instead it logs the situation and exits gracefully since the task is already in the desired state.

View File

@@ -3181,3 +3181,104 @@ describe("Per-task model overrides", () => {
expect(capturedOptions[0].defaultModelId).toBe("gpt-4o");
});
});
// ── Invalid transition error handling tests ─────────────────────────
describe("Invalid transition error handling", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
});
it("does not mark task as failed when invalid transition error occurs on completion", async () => {
const store = createMockStore();
// Mock moveTask to throw invalid transition error (task already moved to done)
store.moveTask.mockRejectedValue(
new Error("Invalid transition: 'done' → 'in-review'. Valid targets: none"),
);
// Mock agent that completes successfully
mockedCreateHaiAgent.mockImplementation(async () => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
// Agent completes work but moveTask will fail
}),
dispose: vi.fn(),
sessionManager: {
getLeafId: vi.fn(),
branchWithSummary: vi.fn(),
},
navigateTree: vi.fn(),
},
} as any;
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "KB-001",
title: "Test",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Should NOT mark task as failed
expect(store.updateTask).not.toHaveBeenCalledWith("KB-001", { status: "failed", error: expect.any(String) });
// Should log informative message
expect(store.logEntry).toHaveBeenCalledWith(
"KB-001",
"Task already moved from 'done' — skipping transition to 'in-review'",
expect.stringContaining("Invalid transition"),
);
});
it("calls onComplete when invalid transition occurs after successful execution", async () => {
const store = createMockStore();
const onComplete = vi.fn();
// Mock moveTask to throw invalid transition error
store.moveTask.mockRejectedValue(
new Error("Invalid transition: 'in-progress' → 'in-review'. Valid targets: todo, triage"),
);
mockedCreateHaiAgent.mockImplementation(async () => {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
sessionManager: {
getLeafId: vi.fn(),
branchWithSummary: vi.fn(),
},
navigateTree: vi.fn(),
},
} as any;
});
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
await executor.execute({
id: "KB-002",
title: "Test",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// onComplete should be called even when invalid transition occurs
expect(onComplete).toHaveBeenCalled();
expect(onComplete).toHaveBeenCalledWith(expect.objectContaining({ id: "KB-002" }));
});
});

View File

@@ -513,6 +513,17 @@ export class TaskExecutor {
// Dependency added mid-execution — discard worktree and move to triage
this.depAborted.delete(task.id);
await this.handleDepAbortCleanup(task.id, worktreePath);
} else if (err.message?.includes("Invalid transition")) {
// Task was moved by user/process while executor was running — already in desired state
// This check must come before pausedAborted since it's more specific
const transitionMatch = err.message.match(/Invalid transition: '([^']+)' → '([^']+)'/);
const fromColumn = transitionMatch?.[1] ?? "unknown";
const toColumn = transitionMatch?.[2] ?? "unknown";
const logMessage = `Task already moved from '${fromColumn}' — skipping transition to '${toColumn}'`;
executorLog.log(`${task.id} ${logMessage}`);
await this.store.logEntry(task.id, logMessage, err.message);
// Task finished successfully (just already moved), so call onComplete
this.options.onComplete?.(task);
} else if (this.pausedAborted.has(task.id)) {
// Task was paused mid-execution — move to todo, don't mark as failed
executorLog.log(`${task.id} paused — moving to todo`);