fix: preserve signal-gated workflow proceed action (#3572)
This commit is contained in:
@@ -743,6 +743,7 @@ func mapKanbanStepState(step taskdto.WorkflowStepDTO) map[string]any {
|
||||
"position": step.Position,
|
||||
"events": step.Events,
|
||||
"allow_manual_move": step.AllowManualMove,
|
||||
"auto_advance_requires_signal": step.AutoAdvanceRequiresSignal,
|
||||
"prompt": step.Prompt,
|
||||
"is_start_step": step.IsStartStep,
|
||||
"show_in_command_panel": step.ShowInCommandPanel,
|
||||
|
||||
@@ -42,6 +42,16 @@ func TestMapKanbanStepStateIncludesProfileSessionPolicies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapKanbanStepStateIncludesAutoAdvanceRequiresSignal(t *testing.T) {
|
||||
step := mapKanbanStepState(taskdto.WorkflowStepDTO{
|
||||
ID: "step-signal-gated",
|
||||
AutoAdvanceRequiresSignal: true,
|
||||
})
|
||||
if step["auto_advance_requires_signal"] != true {
|
||||
t.Fatalf("auto_advance_requires_signal = %#v, want true", step["auto_advance_requires_signal"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMapKanbanTaskStateIncludesAutoStartFailed regression-tests Review round
|
||||
// 2's MAJOR finding: mapKanbanTaskState is a camelCase whitelist that omitted
|
||||
// auto_start_failed, so a task whose auto-start already failed rendered with
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkflowSnapshot } from "@/lib/types/http";
|
||||
import { mapSnapshotToKanban } from "./session-task-switcher-sheet-helpers";
|
||||
|
||||
describe("mapSnapshotToKanban", () => {
|
||||
it("preserves the signal-gated flag when switching workflows", () => {
|
||||
const snapshot = {
|
||||
steps: [
|
||||
{
|
||||
id: "step-1",
|
||||
name: "Review",
|
||||
position: 1,
|
||||
color: "bg-blue-500",
|
||||
auto_advance_requires_signal: true,
|
||||
},
|
||||
],
|
||||
tasks: [],
|
||||
} as unknown as WorkflowSnapshot;
|
||||
|
||||
const state = mapSnapshotToKanban(snapshot, "workflow-1");
|
||||
|
||||
expect(state.steps[0]).toMatchObject({
|
||||
auto_advance_requires_signal: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export function mapSnapshotToKanban(snapshot: WorkflowSnapshot, newWorkflowId: s
|
||||
events: step.events,
|
||||
// Preserve optional step capabilities until the next full reload.
|
||||
allow_manual_move: step.allow_manual_move,
|
||||
auto_advance_requires_signal: step.auto_advance_requires_signal,
|
||||
prompt: step.prompt,
|
||||
is_start_step: step.is_start_step,
|
||||
show_in_command_panel: step.show_in_command_panel,
|
||||
|
||||
@@ -1267,6 +1267,7 @@ export class ApiClient {
|
||||
on_budget_alert?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
on_agent_error?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
};
|
||||
auto_advance_requires_signal?: boolean;
|
||||
wip_limit?: number;
|
||||
pull_from_step_id?: string | null;
|
||||
cancel_triggers_turn_complete?: boolean;
|
||||
|
||||
@@ -3,6 +3,52 @@ import { routeMainWebSocketWithPromptDrop } from "../../helpers/ws-drop";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
test.describe("Manual proceed to next workflow step", () => {
|
||||
test("shows next step action for an idle signal-gated transition", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const workflow = await apiClient.createWorkflow(
|
||||
seedData.workspaceId,
|
||||
"Signal Gated Proceed Workflow",
|
||||
);
|
||||
const signalStep = await apiClient.createWorkflowStep(workflow.id, "Signal Gate", 0);
|
||||
const reviewStep = await apiClient.createWorkflowStep(workflow.id, "Review", 1);
|
||||
|
||||
await apiClient.updateWorkflowStep(signalStep.id, {
|
||||
prompt: 'e2e:message("signal-gated turn complete")\n{{task_prompt}}',
|
||||
events: {
|
||||
on_enter: [{ type: "auto_start_agent" }],
|
||||
on_turn_complete: [{ type: "move_to_next" }],
|
||||
},
|
||||
auto_advance_requires_signal: true,
|
||||
});
|
||||
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "Signal Gated Proceed Task", {
|
||||
workflow_id: workflow.id,
|
||||
workflow_step_id: signalStep.id,
|
||||
agent_profile_id: seedData.agentProfileId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await session.waitForChatIdle({ timeout: 30_000 });
|
||||
|
||||
await expect(session.stepperStep("Signal Gate")).toHaveAttribute("aria-current", "step");
|
||||
await expect(session.proceedNextStepButton()).toBeVisible();
|
||||
|
||||
await session.proceedNextStepButton().click();
|
||||
|
||||
await expect
|
||||
.poll(async () => (await apiClient.getTask(task.id)).workflow_step_id, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBe(reviewStep.id);
|
||||
await expect(session.stepperStep("Review")).toHaveAttribute("aria-current", "step");
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression test: moving a task out of a plan-mode step must disable plan mode
|
||||
* and show the next step's auto-start prompt in chat.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
const WORKSPACE_ID = "ws-A";
|
||||
const WORKFLOW_ID = "wf-A";
|
||||
const mockSetWorkflowSnapshot = vi.fn();
|
||||
const mockFetchWorkflowSnapshot = vi.fn();
|
||||
|
||||
type Workflow = { id: string; workspaceId: string; name: string };
|
||||
type MockState = {
|
||||
connection: { status: string };
|
||||
workspaces: { activeId: string | null };
|
||||
workspaceContextGeneration: number;
|
||||
workflows: { items: Workflow[] };
|
||||
kanbanMulti: { snapshots: Record<string, unknown>; isLoading: boolean };
|
||||
clearKanbanMulti: () => void;
|
||||
setKanbanMultiLoading: () => void;
|
||||
setWorkflowSnapshot: typeof mockSetWorkflowSnapshot;
|
||||
};
|
||||
|
||||
let mockState: MockState;
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: MockState) => unknown) => selector(mockState),
|
||||
useAppStoreApi: () => ({ getState: () => mockState }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
fetchWorkflowSnapshot: (...args: unknown[]) => mockFetchWorkflowSnapshot(...args),
|
||||
}));
|
||||
|
||||
import { useAllWorkflowSnapshots } from "./use-all-workflow-snapshots";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchWorkflowSnapshot.mockResolvedValue({ steps: [], tasks: [] });
|
||||
mockState = {
|
||||
connection: { status: "connected" },
|
||||
workspaces: { activeId: WORKSPACE_ID },
|
||||
workspaceContextGeneration: 0,
|
||||
workflows: { items: [{ id: WORKFLOW_ID, workspaceId: WORKSPACE_ID, name: "A" }] },
|
||||
kanbanMulti: { snapshots: {}, isLoading: false },
|
||||
clearKanbanMulti: vi.fn(),
|
||||
setKanbanMultiLoading: vi.fn(),
|
||||
setWorkflowSnapshot: mockSetWorkflowSnapshot,
|
||||
};
|
||||
});
|
||||
|
||||
describe("useAllWorkflowSnapshots signal-gated mapping", () => {
|
||||
it("preserves the signal-gated flag in refreshed workflow snapshots", async () => {
|
||||
mockFetchWorkflowSnapshot.mockResolvedValueOnce({
|
||||
steps: [{ id: "step-1", name: "Review", position: 1, auto_advance_requires_signal: true }],
|
||||
tasks: [],
|
||||
});
|
||||
|
||||
renderHook(() => useAllWorkflowSnapshots(WORKSPACE_ID));
|
||||
|
||||
await waitFor(() => expect(mockSetWorkflowSnapshot).toHaveBeenCalled());
|
||||
expect(mockSetWorkflowSnapshot.mock.calls[0][1].steps[0]).toMatchObject({
|
||||
auto_advance_requires_signal: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -196,6 +196,7 @@ async function fetchAndWriteSnapshot(
|
||||
position: step.position,
|
||||
events: step.events,
|
||||
allow_manual_move: step.allow_manual_move,
|
||||
auto_advance_requires_signal: step.auto_advance_requires_signal,
|
||||
prompt: step.prompt,
|
||||
is_start_step: step.is_start_step,
|
||||
show_in_command_panel: step.show_in_command_panel,
|
||||
|
||||
@@ -16,6 +16,9 @@ const mockAppState = vi.hoisted(() => ({
|
||||
setTaskPlan: vi.fn(),
|
||||
} as Record<string, unknown>,
|
||||
}));
|
||||
const WORKFLOW_ID = "workflow-1";
|
||||
const SESSION_ID = "session-1";
|
||||
const TASK_ID = "task-1";
|
||||
const mockContextFilesStore = vi.hoisted(() => ({
|
||||
value: {
|
||||
removeFile: vi.fn(),
|
||||
@@ -73,6 +76,7 @@ import {
|
||||
buildImplementPlanContent,
|
||||
collectImplementPlanInput,
|
||||
readContextFilesMeta,
|
||||
useNextWorkflowStep,
|
||||
usePlanActions,
|
||||
} from "./use-plan-actions";
|
||||
|
||||
@@ -163,7 +167,7 @@ function setUpPlanActionsState() {
|
||||
mockAppState.value = {
|
||||
...mockAppState.value,
|
||||
kanban: {
|
||||
workflowId: "workflow-1",
|
||||
workflowId: WORKFLOW_ID,
|
||||
steps: [
|
||||
{
|
||||
id: "plan-step",
|
||||
@@ -178,11 +182,165 @@ function setUpPlanActionsState() {
|
||||
events: { on_enter: [{ type: "auto_start_agent" }] },
|
||||
},
|
||||
],
|
||||
tasks: [{ id: "task-1", workflowStepId: "plan-step" }],
|
||||
tasks: [{ id: TASK_ID, workflowStepId: "plan-step" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useNextWorkflowStep visibility", () => {
|
||||
beforeEach(setUpPlanActionsState);
|
||||
|
||||
it("exposes the next step for an idle signal-gated turn-complete move", () => {
|
||||
mockAppState.value = {
|
||||
...mockAppState.value,
|
||||
kanban: {
|
||||
workflowId: WORKFLOW_ID,
|
||||
steps: [
|
||||
{
|
||||
id: "plan-step",
|
||||
title: "Plan",
|
||||
position: 1,
|
||||
auto_advance_requires_signal: true,
|
||||
events: { on_turn_complete: [{ type: "move_to_next" }] },
|
||||
},
|
||||
{
|
||||
id: "work-step",
|
||||
title: "Work",
|
||||
position: 2,
|
||||
events: { on_enter: [{ type: "auto_start_agent" }] },
|
||||
},
|
||||
],
|
||||
tasks: [{ id: TASK_ID, workflowStepId: "plan-step" }],
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useNextWorkflowStep(TASK_ID));
|
||||
|
||||
expect(result.current.proceedStepName).toBe("Work");
|
||||
});
|
||||
|
||||
it("suppresses the next step for an ungated turn-complete move", () => {
|
||||
mockAppState.value = {
|
||||
...mockAppState.value,
|
||||
kanban: {
|
||||
workflowId: WORKFLOW_ID,
|
||||
steps: [
|
||||
{
|
||||
id: "plan-step",
|
||||
title: "Plan",
|
||||
position: 1,
|
||||
auto_advance_requires_signal: false,
|
||||
events: { on_turn_complete: [{ type: "move_to_next" }] },
|
||||
},
|
||||
{
|
||||
id: "work-step",
|
||||
title: "Work",
|
||||
position: 2,
|
||||
events: { on_enter: [{ type: "auto_start_agent" }] },
|
||||
},
|
||||
],
|
||||
tasks: [{ id: TASK_ID, workflowStepId: "plan-step" }],
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useNextWorkflowStep(TASK_ID));
|
||||
|
||||
expect(result.current.proceedStepName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useNextWorkflowStep gated move destinations", () => {
|
||||
beforeEach(setUpPlanActionsState);
|
||||
|
||||
it("suppresses the next step for a gated move to a previous step", () => {
|
||||
mockAppState.value = {
|
||||
...mockAppState.value,
|
||||
kanban: {
|
||||
workflowId: WORKFLOW_ID,
|
||||
steps: [
|
||||
{
|
||||
id: "plan-step",
|
||||
title: "Plan",
|
||||
position: 1,
|
||||
auto_advance_requires_signal: true,
|
||||
events: { on_turn_complete: [{ type: "move_to_previous" }] },
|
||||
},
|
||||
{
|
||||
id: "work-step",
|
||||
title: "Work",
|
||||
position: 2,
|
||||
events: { on_enter: [{ type: "auto_start_agent" }] },
|
||||
},
|
||||
],
|
||||
tasks: [{ id: TASK_ID, workflowStepId: "plan-step" }],
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useNextWorkflowStep(TASK_ID));
|
||||
|
||||
expect(result.current.proceedStepName).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses the next step for a gated move to a configured step", () => {
|
||||
mockAppState.value = {
|
||||
...mockAppState.value,
|
||||
kanban: {
|
||||
workflowId: WORKFLOW_ID,
|
||||
steps: [
|
||||
{
|
||||
id: "plan-step",
|
||||
title: "Plan",
|
||||
position: 1,
|
||||
auto_advance_requires_signal: true,
|
||||
events: {
|
||||
on_turn_complete: [{ type: "move_to_step", config: { step_id: "other-step" } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "work-step",
|
||||
title: "Work",
|
||||
position: 2,
|
||||
events: { on_enter: [{ type: "auto_start_agent" }] },
|
||||
},
|
||||
],
|
||||
tasks: [{ id: TASK_ID, workflowStepId: "plan-step" }],
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useNextWorkflowStep(TASK_ID));
|
||||
|
||||
expect(result.current.proceedStepName).toBeNull();
|
||||
});
|
||||
|
||||
it("treats an absent signal-gated flag as ungated", () => {
|
||||
mockAppState.value = {
|
||||
...mockAppState.value,
|
||||
kanban: {
|
||||
workflowId: WORKFLOW_ID,
|
||||
steps: [
|
||||
{
|
||||
id: "plan-step",
|
||||
title: "Plan",
|
||||
position: 1,
|
||||
events: { on_turn_complete: [{ type: "move_to_next" }] },
|
||||
},
|
||||
{
|
||||
id: "work-step",
|
||||
title: "Work",
|
||||
position: 2,
|
||||
events: { on_enter: [{ type: "auto_start_agent" }] },
|
||||
},
|
||||
],
|
||||
tasks: [{ id: TASK_ID, workflowStepId: "plan-step" }],
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useNextWorkflowStep(TASK_ID));
|
||||
|
||||
expect(result.current.proceedStepName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePlanActions", () => {
|
||||
beforeEach(setUpPlanActionsState);
|
||||
|
||||
@@ -197,8 +355,8 @@ describe("usePlanActions", () => {
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePlanActions({
|
||||
resolvedSessionId: "session-1",
|
||||
taskId: "task-1",
|
||||
resolvedSessionId: SESSION_ID,
|
||||
taskId: TASK_ID,
|
||||
planModeEnabled: true,
|
||||
handlePlanModeChange: vi.fn(),
|
||||
chatInputRef: chatInputRef as never,
|
||||
@@ -211,8 +369,8 @@ describe("usePlanActions", () => {
|
||||
it("routes implement through the next auto-start work step", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePlanActions({
|
||||
resolvedSessionId: "session-1",
|
||||
taskId: "task-1",
|
||||
resolvedSessionId: SESSION_ID,
|
||||
taskId: TASK_ID,
|
||||
planModeEnabled: true,
|
||||
handlePlanModeChange: vi.fn(),
|
||||
chatInputRef: { current: null },
|
||||
@@ -223,12 +381,12 @@ describe("usePlanActions", () => {
|
||||
await result.current.implementPlanHandler?.(false);
|
||||
});
|
||||
|
||||
expect(mockMoveTask).toHaveBeenCalledWith("task-1", {
|
||||
workflow_id: "workflow-1",
|
||||
expect(mockMoveTask).toHaveBeenCalledWith(TASK_ID, {
|
||||
workflow_id: WORKFLOW_ID,
|
||||
workflow_step_id: "work-step",
|
||||
position: 0,
|
||||
});
|
||||
expect(mockAppState.value.setPlanMode).toHaveBeenCalledWith("session-1", false);
|
||||
expect(mockAppState.value.setPlanMode).toHaveBeenCalledWith(SESSION_ID, false);
|
||||
});
|
||||
|
||||
it("keeps plan mode enabled when moving to the work step fails", async () => {
|
||||
@@ -236,8 +394,8 @@ describe("usePlanActions", () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const { result } = renderHook(() =>
|
||||
usePlanActions({
|
||||
resolvedSessionId: "session-1",
|
||||
taskId: "task-1",
|
||||
resolvedSessionId: SESSION_ID,
|
||||
taskId: TASK_ID,
|
||||
planModeEnabled: true,
|
||||
handlePlanModeChange: vi.fn(),
|
||||
chatInputRef: { current: null },
|
||||
@@ -255,8 +413,8 @@ describe("usePlanActions", () => {
|
||||
it("does not expose the implement handler outside plan mode", () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePlanActions({
|
||||
resolvedSessionId: "session-1",
|
||||
taskId: "task-1",
|
||||
resolvedSessionId: SESSION_ID,
|
||||
taskId: TASK_ID,
|
||||
planModeEnabled: false,
|
||||
handlePlanModeChange: vi.fn(),
|
||||
chatInputRef: { current: null },
|
||||
@@ -277,8 +435,8 @@ describe("usePlanActions proceed failures", () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const { result } = renderHook(() =>
|
||||
usePlanActions({
|
||||
resolvedSessionId: "session-1",
|
||||
taskId: "task-1",
|
||||
resolvedSessionId: SESSION_ID,
|
||||
taskId: TASK_ID,
|
||||
planModeEnabled: true,
|
||||
handlePlanModeChange: vi.fn(),
|
||||
chatInputRef: { current: null },
|
||||
|
||||
@@ -50,13 +50,16 @@ export function useNextWorkflowStep(taskId: string | null) {
|
||||
return { currentStep: current, nextStep: next };
|
||||
}, [sortedSteps, taskStepId]);
|
||||
|
||||
const currentStepAutoTransitions = useMemo(
|
||||
() =>
|
||||
currentStep?.events?.on_turn_complete?.some((a) =>
|
||||
AUTO_TRANSITION_ACTIONS.includes(a.type),
|
||||
) ?? false,
|
||||
[currentStep],
|
||||
);
|
||||
const currentStepAutoTransitions = useMemo(() => {
|
||||
if (!currentStep) return false;
|
||||
return (
|
||||
currentStep.events?.on_turn_complete?.some(
|
||||
(a) =>
|
||||
AUTO_TRANSITION_ACTIONS.includes(a.type) &&
|
||||
(currentStep.auto_advance_requires_signal !== true || a.type !== "move_to_next"),
|
||||
) ?? false
|
||||
);
|
||||
}, [currentStep]);
|
||||
|
||||
const nextStepIsWorkStep = useMemo(() => {
|
||||
if (!nextStep) return false;
|
||||
|
||||
@@ -102,6 +102,19 @@ describe("snapshotToState", () => {
|
||||
expect(state.kanban?.tasks[0]?.metadata).toEqual(snapshot.tasks[0].metadata);
|
||||
});
|
||||
|
||||
it("hydrates the signal-gated flag into the initial kanban step state", () => {
|
||||
const snapshot = snapshotWithPendingAction(undefined);
|
||||
Object.assign(snapshot.steps[0], {
|
||||
auto_advance_requires_signal: true,
|
||||
});
|
||||
|
||||
const state = snapshotToState(snapshot);
|
||||
|
||||
expect(state.kanban?.steps[0]).toMatchObject({
|
||||
auto_advance_requires_signal: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, 0],
|
||||
[3, 3],
|
||||
|
||||
@@ -139,6 +139,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial<AppState> {
|
||||
position: step.position,
|
||||
events: step.events,
|
||||
allow_manual_move: step.allow_manual_move,
|
||||
auto_advance_requires_signal: step.auto_advance_requires_signal,
|
||||
prompt: step.prompt,
|
||||
is_start_step: step.is_start_step,
|
||||
show_in_command_panel: step.show_in_command_panel,
|
||||
|
||||
@@ -45,6 +45,7 @@ export type KanbanState = {
|
||||
position: number;
|
||||
events?: KanbanStepEvents;
|
||||
allow_manual_move?: boolean;
|
||||
auto_advance_requires_signal?: boolean;
|
||||
prompt?: string;
|
||||
is_start_step?: boolean;
|
||||
show_in_command_panel?: boolean;
|
||||
|
||||
@@ -62,6 +62,7 @@ export type KanbanUpdatePayload = {
|
||||
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
};
|
||||
show_in_command_panel?: boolean;
|
||||
auto_advance_requires_signal?: boolean;
|
||||
wip_limit?: number;
|
||||
pull_from_step_id?: string | null;
|
||||
}>;
|
||||
@@ -290,6 +291,7 @@ export type StepPayload = {
|
||||
is_start_step?: boolean;
|
||||
allow_manual_move?: boolean;
|
||||
show_in_command_panel?: boolean;
|
||||
auto_advance_requires_signal?: boolean;
|
||||
auto_archive_after_hours?: number;
|
||||
agent_profile_id?: string;
|
||||
profile_session_start_policy?: WorkflowProfileSessionStartPolicy;
|
||||
|
||||
@@ -525,6 +525,7 @@ export type WorkflowStepDTO = {
|
||||
profile_session_start_policy?: WorkflowProfileSessionStartPolicy;
|
||||
profile_session_end_policy?: WorkflowProfileSessionEndPolicy;
|
||||
stage_type?: "work" | "review" | "approval" | "custom";
|
||||
auto_advance_requires_signal?: boolean;
|
||||
wip_limit?: number;
|
||||
pull_from_step_id?: string | null;
|
||||
created_at?: string;
|
||||
|
||||
@@ -42,8 +42,8 @@ function makeUpdateMessage(workflowId: string, tasks: unknown[], steps: unknown[
|
||||
};
|
||||
}
|
||||
|
||||
describe("kanban.update handler — primarySessionId preservation", () => {
|
||||
it("preserves workflow step WIP fields", () => {
|
||||
describe("kanban.update handler — signal-gated workflow steps", () => {
|
||||
it("preserves the signal-gated flag in live step updates", () => {
|
||||
const store = makeStore();
|
||||
const handler = registerKanbanHandlers(store)["kanban.update"]!;
|
||||
|
||||
@@ -57,6 +57,7 @@ describe("kanban.update handler — primarySessionId preservation", () => {
|
||||
title: "Review",
|
||||
position: 1,
|
||||
color: "bg-blue-500",
|
||||
auto_advance_requires_signal: true,
|
||||
wip_limit: 2,
|
||||
pull_from_step_id: "step-0",
|
||||
},
|
||||
@@ -67,9 +68,12 @@ describe("kanban.update handler — primarySessionId preservation", () => {
|
||||
expect(store.getState().kanban.steps[0]).toMatchObject({
|
||||
wip_limit: 2,
|
||||
pull_from_step_id: "step-0",
|
||||
auto_advance_requires_signal: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("kanban.update handler — primarySessionId preservation", () => {
|
||||
it("preserves primarySessionId from existing tasks", () => {
|
||||
const store = makeStore({
|
||||
kanban: {
|
||||
|
||||
@@ -141,6 +141,7 @@ export function registerKanbanHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
events: step.events,
|
||||
show_in_command_panel: step.show_in_command_panel,
|
||||
agent_profile_id: step.agent_profile_id,
|
||||
auto_advance_requires_signal: step.auto_advance_requires_signal,
|
||||
wip_limit: step.wip_limit,
|
||||
pull_from_step_id: step.pull_from_step_id ?? null,
|
||||
}));
|
||||
|
||||
@@ -231,12 +231,14 @@ describe("workflow step handlers", () => {
|
||||
color: REVIEW_STEP_COLOR,
|
||||
wip_limit: 2,
|
||||
pull_from_step_id: "step-0",
|
||||
auto_advance_requires_signal: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.getState().kanban.steps[0]).toMatchObject({
|
||||
wip_limit: 2,
|
||||
pull_from_step_id: "step-0",
|
||||
auto_advance_requires_signal: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ function stepFromPayload(step: any) {
|
||||
events: step.events,
|
||||
show_in_command_panel: step.show_in_command_panel,
|
||||
allow_manual_move: step.allow_manual_move,
|
||||
auto_advance_requires_signal: step.auto_advance_requires_signal,
|
||||
prompt: step.prompt,
|
||||
is_start_step: step.is_start_step,
|
||||
agent_profile_id: step.agent_profile_id,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
created: 2026-09-10
|
||||
status: completed
|
||||
requirements:
|
||||
- REQ-TASKS-WORKFLOW-EXPLICIT-COMPLETION-SIGNAL-002
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/workflow-signal-gated-manual-move-visibility.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Signal-Gated Workflow Proceed Control
|
||||
|
||||
## Overview
|
||||
|
||||
Preserve `auto_advance_requires_signal` in every workflow-step projection used
|
||||
by the task UI. Then refine the shared next-step derivation so a signal-gated
|
||||
`on_turn_complete` `move_to_next` action remains available while ungated moves
|
||||
and gated moves to another configured destination suppress the adjacent-step
|
||||
composer action. Standard chat and passthrough composers retain their existing
|
||||
busy-state gate and manual task-move behavior.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Carry `auto_advance_requires_signal` from HTTP and WebSocket workflow-step
|
||||
payloads into active and cached Kanban state.
|
||||
- Show the existing next-step composer action after an idle signal-gated
|
||||
`move_to_next` turn.
|
||||
- Continue suppressing the action for ungated turn-complete moves and for
|
||||
signal-gated `move_to_previous` or `move_to_step` actions whose destinations
|
||||
the adjacent-step control cannot represent.
|
||||
- Preserve the existing busy-state guard, click behavior, error handling, and
|
||||
mobile task-drawer path.
|
||||
- Add focused unit and production-build browser regression coverage.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Creating the ADR 0015 `manual_fallback` completion signal.
|
||||
- Changing backend transition, clarification, cancellation, or
|
||||
signal-persistence semantics.
|
||||
- Adding new copy, controls, layout, card styling, or telemetry.
|
||||
- Changing which workflow-step action types count as move actions.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Preserve the signal-gated field
|
||||
|
||||
- Add `auto_advance_requires_signal?: boolean` to the step shape in
|
||||
`KanbanState`.
|
||||
- Copy the value in SSR snapshot mapping, multi-workflow snapshot refresh,
|
||||
mobile workspace-switch mapping, workflow-step WebSocket mapping, and the
|
||||
live Kanban update projection.
|
||||
- Add mapper tests so initial load, cached refresh, workspace switch, and live
|
||||
update cannot silently discard the field again.
|
||||
|
||||
### Refine next-step visibility
|
||||
|
||||
- Keep the existing detection of `move_to_next`, `move_to_previous`, and
|
||||
`move_to_step` in current-step `on_turn_complete` actions.
|
||||
- Treat that configuration as an automatic transition for composer suppression
|
||||
when the step is ungated, or when the configured action is not `move_to_next`.
|
||||
Only a signal-gated `move_to_next` action is an exception.
|
||||
- Preserve the Go boot-state mapper field so direct task-page hydration has the
|
||||
same signal-gated policy input as subsequent client refreshes.
|
||||
- Leave `ChatStatusBar` and `PassthroughToolbar` unchanged; both already hide
|
||||
the shared next-step action while the agent is busy.
|
||||
- Leave `proceed()` unchanged so the action continues to use the normal task
|
||||
move API and existing error handling.
|
||||
|
||||
## Tests
|
||||
|
||||
- Hook tests prove a signal-gated `move_to_next` exposes the next step, an
|
||||
ungated move remains suppressed, gated `move_to_previous` and `move_to_step`
|
||||
remain suppressed, and an omitted flag keeps the legacy ungated behavior.
|
||||
- Mapper tests prove the field survives initial hydration, multi-workflow
|
||||
refresh, mobile workspace switching, workflow-step WebSocket updates, and
|
||||
live Kanban updates. A Go boot-state mapper test covers direct task-page
|
||||
hydration.
|
||||
- Existing composer and passthrough tests continue to cover the busy-state
|
||||
display gate because their input contract does not change.
|
||||
|
||||
## E2E test
|
||||
|
||||
Extend `apps/web/e2e/tests/workflow/workflow-step-proceed.spec.ts` with a workflow
|
||||
whose first step has an `on_turn_complete` `move_to_next` action and
|
||||
`auto_advance_requires_signal=true`. Let the mock agent finish without a signal,
|
||||
assert that the task remains on the step, assert the next-step composer action is
|
||||
visible, select it, wait for the task API to report the target workflow step, and
|
||||
then assert the stepper reflects the normal move. Extend the E2E API client update
|
||||
type to seed the existing workflow-step field.
|
||||
|
||||
The existing mobile task-drawer move test remains the parity check for the
|
||||
phone-specific path. No new responsive markup is introduced.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [completed] [Task 01: Preserve signal-gated proceed visibility](task-01-preserve-signal-gated-proceed-visibility.md)
|
||||
|
||||
## Verification
|
||||
|
||||
Run the focused boot-state mapper test from `apps/backend`:
|
||||
|
||||
```bash
|
||||
go test ./internal/backendapp
|
||||
```
|
||||
|
||||
Run focused tests from `apps/web`:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run hooks/domains/kanban/use-plan-actions.test.ts lib/ssr/mapper.test.ts lib/ws/handlers/workflows.test.ts lib/ws/handlers/kanban.test.ts hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-all-workflow-snapshots.signal-gated.test.ts components/task/mobile/session-task-switcher-sheet-helpers.test.ts
|
||||
pnpm run typecheck
|
||||
pnpm exec eslint hooks/domains/kanban/use-plan-actions.ts hooks/domains/kanban/use-plan-actions.test.ts lib/state/slices/kanban/types.ts lib/ssr/mapper.ts lib/ssr/mapper.test.ts lib/ws/handlers/workflows.ts lib/ws/handlers/workflows.test.ts lib/ws/handlers/kanban.ts lib/ws/handlers/kanban.test.ts hooks/domains/kanban/use-all-workflow-snapshots.ts hooks/domains/kanban/use-all-workflow-snapshots.signal-gated.test.ts components/task/mobile/session-task-switcher-sheet-helpers.ts components/task/mobile/session-task-switcher-sheet-helpers.test.ts e2e/helpers/api-client.ts e2e/tests/workflow/workflow-step-proceed.spec.ts
|
||||
pnpm e2e:run tests/workflow/workflow-step-proceed.spec.ts -- --grep "shows next step action for an idle signal-gated transition" --retries=0
|
||||
pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-task-actions.spec.ts -- --grep "moves a task to another step from the mobile task drawer" --retries=0
|
||||
```
|
||||
|
||||
Run specification checks from the repository root:
|
||||
|
||||
```bash
|
||||
python3 scripts/lint-spec-files.test.py
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
- Preserving the field in only one hydration path would make the control appear
|
||||
or disappear after reload, workflow refresh, or a live settings update.
|
||||
- Treating every gated step as manually movable would expose the action without
|
||||
a configured adjacent destination; the derivation must still require an
|
||||
applicable `on_turn_complete` `move_to_next` action and a next step. Gated
|
||||
`move_to_previous` and `move_to_step` actions remain suppressed because the
|
||||
existing control submits the adjacent next step.
|
||||
- Removing the busy-state guard could race a manual move against an active turn;
|
||||
the presentation components keep that guard.
|
||||
- Omitting the server boot mapper field would make direct task-page hydration
|
||||
disagree with later workflow refreshes; the boot mapper regression test covers
|
||||
this path.
|
||||
- Conflating this action with ADR 0015's signal-writing fallback would silently
|
||||
broaden backend semantics and telemetry. This plan deliberately reuses the
|
||||
existing manual move only.
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
---
|
||||
id: "01-preserve-signal-gated-proceed-visibility"
|
||||
title: "Preserve signal-gated proceed visibility"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-TASKS-WORKFLOW-EXPLICIT-COMPLETION-SIGNAL-002
|
||||
acceptance_criteria:
|
||||
- AC-TASKS-WORKFLOW-EXPLICIT-COMPLETION-SIGNAL-002.1
|
||||
- AC-TASKS-WORKFLOW-EXPLICIT-COMPLETION-SIGNAL-002.2
|
||||
- AC-TASKS-WORKFLOW-EXPLICIT-COMPLETION-SIGNAL-002.3
|
||||
- AC-TASKS-WORKFLOW-EXPLICIT-COMPLETION-SIGNAL-002.4
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/workflow-signal-gated-manual-move-visibility.md
|
||||
---
|
||||
|
||||
# Task 01: Preserve Signal-Gated Proceed Visibility
|
||||
|
||||
## Summary
|
||||
|
||||
Carry the existing signal-gated workflow-step flag into task UI state and use it
|
||||
to distinguish a truly automatic `move_to_next` transition from a signal-gated
|
||||
one. Prove the adjacent-step composer action stays available after the gated
|
||||
agent becomes idle while unsupported gated destinations remain hidden.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `docs/specs/tasks/requirements/workflow-explicit-completion-signal.md`
|
||||
- `docs/specs/tasks/system-design/workflow-signal-gated-manual-move-visibility.md`
|
||||
- `docs/decisions/0015-explicit-completion-signal-for-auto-advance.md`
|
||||
- `apps/web/hooks/domains/kanban/use-plan-actions.ts`
|
||||
- `apps/web/lib/state/slices/kanban/types.ts`
|
||||
- Existing HTTP, snapshot, workspace-switch, and WebSocket workflow-step
|
||||
mappers.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. **RED:** Add hook cases showing that a gated `on_turn_complete` `move_to_next`
|
||||
action should expose `proceedStepName` while the equivalent ungated move,
|
||||
gated `move_to_previous`, and gated `move_to_step` stay hidden. Add coverage
|
||||
that an omitted flag keeps the legacy ungated behavior.
|
||||
2. **RED:** Add mapping assertions for initial hydration, multi-workflow
|
||||
snapshot refresh, mobile workspace switching, and workflow-step WebSocket
|
||||
updates. Confirm they fail because the flag is absent from `KanbanState` or
|
||||
dropped by each mapper.
|
||||
3. **RED:** Add the focused Playwright scenario and confirm the next-step
|
||||
locator remains hidden after the mock agent returns idle.
|
||||
4. **GREEN:** Add the optional step field, preserve it through each mapper and
|
||||
the Go boot-state mapper, and narrow the automatic-transition suppression
|
||||
condition in `useNextWorkflowStep`.
|
||||
5. **REFACTOR:** Keep the policy in the shared hook. Do not duplicate it in
|
||||
standard chat, passthrough, or mobile components.
|
||||
6. Run the focused unit, type, lint, desktop E2E, and mobile parity commands.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- An idle signal-gated step with a configured `move_to_next` action exposes the
|
||||
existing adjacent-next-step composer action.
|
||||
- The equivalent ungated step does not expose a redundant action.
|
||||
- Signal-gated `move_to_previous` and `move_to_step` actions remain hidden
|
||||
because the existing composer action submits the adjacent next step.
|
||||
- A busy agent still hides the action until it becomes idle.
|
||||
- Initial load, cached snapshot refresh, mobile workspace switching, and live
|
||||
workflow-step updates preserve identical behavior.
|
||||
- Selecting the action uses the existing manual task-move endpoint and advances
|
||||
to the configured next step.
|
||||
- No `step_complete_kandev` signal, `manual_fallback` source, new metric, or new
|
||||
user-facing copy is introduced.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/lib/state/slices/kanban/types.ts`
|
||||
- `apps/web/lib/ssr/mapper.ts`
|
||||
- `apps/web/lib/ssr/mapper.test.ts`
|
||||
- `apps/web/lib/ws/handlers/workflows.ts`
|
||||
- `apps/web/lib/ws/handlers/workflows.test.ts`
|
||||
- `apps/web/lib/ws/handlers/kanban.ts`
|
||||
- `apps/web/lib/ws/handlers/kanban.test.ts`
|
||||
- `apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts`
|
||||
- `apps/web/hooks/domains/kanban/use-all-workflow-snapshots.signal-gated.test.ts`
|
||||
- `apps/web/components/task/mobile/session-task-switcher-sheet-helpers.ts`
|
||||
- `apps/web/components/task/mobile/session-task-switcher-sheet-helpers.test.ts`
|
||||
- `apps/web/hooks/domains/kanban/use-plan-actions.ts`
|
||||
- `apps/web/hooks/domains/kanban/use-plan-actions.test.ts`
|
||||
- `apps/web/e2e/helpers/api-client.ts`
|
||||
- `apps/web/e2e/tests/workflow/workflow-step-proceed.spec.ts`
|
||||
- `apps/backend/internal/backendapp/boot_state_routes.go`
|
||||
- `apps/backend/internal/backendapp/boot_state_routes_test.go`
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
go test ./internal/backendapp
|
||||
cd ../web
|
||||
pnpm exec vitest run hooks/domains/kanban/use-plan-actions.test.ts lib/ssr/mapper.test.ts lib/ws/handlers/workflows.test.ts lib/ws/handlers/kanban.test.ts hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-all-workflow-snapshots.signal-gated.test.ts components/task/mobile/session-task-switcher-sheet-helpers.test.ts
|
||||
pnpm run typecheck
|
||||
pnpm exec eslint hooks/domains/kanban/use-plan-actions.ts hooks/domains/kanban/use-plan-actions.test.ts lib/state/slices/kanban/types.ts lib/ssr/mapper.ts lib/ssr/mapper.test.ts lib/ws/handlers/workflows.ts lib/ws/handlers/workflows.test.ts lib/ws/handlers/kanban.ts lib/ws/handlers/kanban.test.ts hooks/domains/kanban/use-all-workflow-snapshots.ts hooks/domains/kanban/use-all-workflow-snapshots.signal-gated.test.ts components/task/mobile/session-task-switcher-sheet-helpers.ts components/task/mobile/session-task-switcher-sheet-helpers.test.ts e2e/helpers/api-client.ts e2e/tests/workflow/workflow-step-proceed.spec.ts
|
||||
pnpm e2e:run tests/workflow/workflow-step-proceed.spec.ts -- --grep "shows next step action for an idle signal-gated transition" --retries=0
|
||||
pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-task-actions.spec.ts -- --grep "moves a task to another step from the mobile task drawer" --retries=0
|
||||
cd ../..
|
||||
python3 scripts/lint-spec-files.test.py
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- Missing one state projection creates load-order-dependent visibility.
|
||||
- A loose boolean default could turn older payloads into gated steps.
|
||||
- Changing the display components could accidentally weaken the active-turn
|
||||
guard; they should remain consumers of the shared derived value.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Output contract
|
||||
|
||||
Record the RED failures, changed mapper paths, focused verification results,
|
||||
and the final desktop and mobile E2E counts. Update this file to `done` and the
|
||||
plan work-order checkbox to `completed` only after all acceptance criteria pass.
|
||||
|
||||
## Implementation evidence
|
||||
|
||||
- RED unit run: six focused files failed only on the missing signal-gated field
|
||||
and the resulting hidden proceed action. The RED desktop browser run failed
|
||||
because `proceed-next-step` was absent after the mock agent became idle.
|
||||
- Changed projections: SSR hydration, multi-workflow snapshot refresh, mobile
|
||||
workspace switching, workflow-step WebSocket updates, and live Kanban update
|
||||
payloads now preserve `auto_advance_requires_signal`.
|
||||
- GREEN unit run: 7 files, 84 tests passed.
|
||||
- Typecheck passed.
|
||||
- Focused ESLint passed with no errors; it reported four existing complexity or
|
||||
file-size warnings.
|
||||
- Desktop browser regression passed: 1 test.
|
||||
- Mobile task-drawer parity regression passed: 1 test.
|
||||
- Specification tests passed: 30 tests. Full specification lint passed. Diff
|
||||
check passed.
|
||||
- PR fixup remediation: the shared policy now exposes only gated
|
||||
`move_to_next`; gated `move_to_previous` and `move_to_step` remain hidden,
|
||||
and omitted flags remain ungated. The Go boot-state mapper now preserves the
|
||||
field for direct task-page hydration. The browser regression waits for the
|
||||
task API to report the target step before asserting the UI.
|
||||
@@ -166,6 +166,7 @@ signals, and task-scoped scheduling contracts.
|
||||
- [Task priority visibility](system-design/task-priority-visibility.md)
|
||||
- [WIP Limits and Visible Overflow Queues](system-design/wip-limit-pull-system.md)
|
||||
- [Workflow completion-signal payload delivery](system-design/workflow-completion-signal-payload-delivery.md)
|
||||
- [Signal-Gated Manual Move Visibility](system-design/workflow-signal-gated-manual-move-visibility.md)
|
||||
- [Workflow quorum decision recording](system-design/workflow-quorum-decision-recording.md)
|
||||
- [Workflow Step Agent Start Ownership](system-design/workflow-step-agent-start-ownership.md)
|
||||
- [Workflow Step Fixed-Profile Routing](system-design/workflow-step-fixed-profile-routing.md)
|
||||
|
||||
@@ -21,6 +21,35 @@ This document is the migrated task-system source for the capability. The source
|
||||
|
||||
- **AC-TASKS-WORKFLOW-EXPLICIT-COMPLETION-SIGNAL-001.1:** When a consumer uses this capability, the system shall provide the observable behavior and exclusions documented below.
|
||||
|
||||
### REQ-TASKS-WORKFLOW-EXPLICIT-COMPLETION-SIGNAL-002: Signal-Gated Manual Move Visibility
|
||||
|
||||
**Intent:** Keep the normal workflow recovery action available when an explicit
|
||||
completion signal, rather than a bare turn end, controls automatic advancement.
|
||||
|
||||
**User story:** As a user reviewing an idle signal-gated task, I want the
|
||||
composer to offer the configured next workflow step, so that I can move the task
|
||||
when the agent did not emit its completion signal.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-TASKS-WORKFLOW-EXPLICIT-COMPLETION-SIGNAL-002.1:** When an idle task's
|
||||
current step declares an `on_turn_complete` `move_to_next` action and
|
||||
`auto_advance_requires_signal=true`, task composer surfaces that normally
|
||||
provide the adjacent-next-step action shall show the configured next step.
|
||||
This does not extend to `move_to_previous` or `move_to_step`, because the
|
||||
existing composer action submits the adjacent next step and cannot represent
|
||||
either configured destination safely.
|
||||
- **AC-TASKS-WORKFLOW-EXPLICIT-COMPLETION-SIGNAL-002.2:** When a step declares
|
||||
the same `on_turn_complete` `move_to_next` action without requiring a
|
||||
completion signal, task composer surfaces shall continue to suppress the
|
||||
redundant next-step action.
|
||||
- **AC-TASKS-WORKFLOW-EXPLICIT-COMPLETION-SIGNAL-002.3:** While the task's agent
|
||||
is busy, task composer surfaces shall hide the next-step action and shall
|
||||
evaluate its visibility again when the agent becomes idle.
|
||||
- **AC-TASKS-WORKFLOW-EXPLICIT-COMPLETION-SIGNAL-002.4:** The signal-gated
|
||||
setting shall produce the same next-step visibility after initial hydration,
|
||||
workflow snapshot refresh, and live workflow-step updates.
|
||||
|
||||
## Migrated source detail
|
||||
|
||||
## Why
|
||||
@@ -37,6 +66,12 @@ Signal-gated Kanban and Office workflows need a reliable distinction between an
|
||||
- Creating or loading an ACP session supplies the local Kandev MCP server. After a transient MCP reconnect, the client can list and call `step_complete_kandev` again without a user message or process restart.
|
||||
- Kandev does not pin the Claude ACP bridge version as part of this feature. Bridge selection retains the existing unversioned package behavior; diagnostics continue to report the version returned during ACP initialization.
|
||||
- Existing idempotency, clarification barriers, and re-open semantics remain unchanged. ADR 0015's planned manual "Mark complete & advance" fallback is not currently implemented and is outside this reliability fix.
|
||||
- The ordinary adjacent-next-step action remains available after an idle turn
|
||||
on a signal-gated step whose `on_turn_complete` action is `move_to_next`. It
|
||||
uses the existing manual task-move path and does not create a
|
||||
`manual_fallback` completion signal. Signal-gated `move_to_previous` and
|
||||
`move_to_step` actions remain outside this visibility exception because the
|
||||
existing composer action always submits the adjacent next step.
|
||||
- Explicit user-cancel completion is owned by the [Cancelled Turn Completion](workflow-cancelled-turn-completion.md) spec. When a step opts into that policy, the user's cancel action is a human completion decision and may run `on_turn_complete` without an agent-emitted signal; internal cancellation paths remain non-completing.
|
||||
|
||||
## API Surface
|
||||
@@ -87,12 +122,30 @@ The pending completion signal continues to use `TaskSession.Metadata` as specifi
|
||||
- **GIVEN** a signal-gated Office workflow step, **WHEN** its first-turn context is generated, **THEN** the context instructs the agent to call `step_complete_kandev` as the final action.
|
||||
- **GIVEN** a task-mode client has connected to Kandev MCP, **WHEN** its MCP connection drops and reconnects, **THEN** the client can list and call `step_complete_kandev` without another user message.
|
||||
- **GIVEN** a signal-gated workflow step, **WHEN** the agent finishes without calling the tool, **THEN** the task remains on the current step and no automatic transition runs.
|
||||
- **GIVEN** a signal-gated workflow step with a configured `move_to_next` move,
|
||||
**WHEN** its agent becomes idle without calling the completion tool, **THEN**
|
||||
the normal next-step action is visible in task composer surfaces that provide
|
||||
that action.
|
||||
- **GIVEN** an ungated workflow step with the same configured `move_to_next` move,
|
||||
**WHEN** its composer is rendered, **THEN** the redundant next-step action is
|
||||
not visible because the turn-complete transition can advance automatically.
|
||||
- **GIVEN** a signal-gated workflow step with a configured `move_to_next` move,
|
||||
**WHEN** its agent is busy, **THEN** the next-step action remains hidden until
|
||||
the agent returns to an idle state.
|
||||
- **GIVEN** a signal-gated workflow step with a configured `move_to_previous` or
|
||||
`move_to_step` move, **WHEN** its composer is rendered, **THEN** the existing
|
||||
adjacent-next-step action remains hidden because it cannot submit the
|
||||
configured destination.
|
||||
- **GIVEN** a signal-gated workflow step that also enables explicit user-cancel completion, **WHEN** the user cancels the active turn, **THEN** the configured completion transition may run without `step_complete_kandev` as defined by the cancelled-turn completion policy.
|
||||
- **GIVEN** a client that displays fully qualified MCP names, **WHEN** it resolves the completion instruction, **THEN** it can associate canonical `step_complete_kandev` with its qualified runtime alias.
|
||||
- **GIVEN** Claude ACP initializes, **WHEN** the bridge reports its identity, **THEN** Kandev records the reported bridge name and version in diagnostics without constraining package resolution in this feature.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Changing completion-signal persistence or ordinary halt semantics, or implementing ADR 0015's planned manual fallback UI. Explicit user-cancel behavior is defined separately by the cancelled-turn completion spec.
|
||||
- Changing completion-signal persistence or ordinary halt semantics, or
|
||||
implementing ADR 0015's planned signal-writing "Mark complete & advance"
|
||||
fallback. The ordinary manual task-move action remains distinct from that
|
||||
future fallback. Explicit user-cancel behavior is defined separately by the
|
||||
cancelled-turn completion spec.
|
||||
- Adding vendor-specific eager-load metadata; agents are expected to use normal MCP tool discovery.
|
||||
- Implementing a generic MCP proxy or replacing the ACP bridge.
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
status: current
|
||||
system: tasks
|
||||
requirements:
|
||||
- REQ-TASKS-WORKFLOW-EXPLICIT-COMPLETION-SIGNAL-002
|
||||
created: 2026-09-10
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Signal-Gated Manual Move Visibility System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
The task system owns whether a workflow transition is automatic, signal-gated,
|
||||
or manually recoverable. The web application presents that state in the normal
|
||||
chat and passthrough composer controls. This design changes only the visibility
|
||||
policy for the existing next-step action. It does not change transition
|
||||
execution, completion-signal persistence, clarification handling, or the future
|
||||
ADR 0015 `manual_fallback` signal path.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-TASKS-WORKFLOW-EXPLICIT-COMPLETION-SIGNAL-002` | [Control flow](#control-flow), [Failure and recovery](#failure-and-recovery) |
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
- The workflow HTTP and WebSocket payloads remain the authority for
|
||||
`auto_advance_requires_signal` and step events.
|
||||
- `KanbanState.steps` retains both values for the active workflow and cached
|
||||
workflow snapshots.
|
||||
- HTTP hydration, multi-workflow snapshot refresh, mobile workspace switching,
|
||||
workflow-step WebSocket mapping, and live Kanban update mapping preserve the
|
||||
signal-gated flag.
|
||||
- `useNextWorkflowStep` exposes the existing adjacent-next-step action only for
|
||||
a signal-gated `move_to_next` action. Ungated move actions remain suppressed,
|
||||
as do signal-gated `move_to_previous` and `move_to_step` actions, because the
|
||||
existing `moveTask` operation submits the adjacent next step rather than a
|
||||
configured arbitrary destination.
|
||||
- `ChatStatusBar` and `PassthroughToolbar` retain their existing busy-state gate
|
||||
and consume the shared next-step projection.
|
||||
- The phone task drawer remains the alternate manual step-move surface. This
|
||||
correction adds no phone-only layout or interaction.
|
||||
|
||||
## Data and contracts
|
||||
|
||||
`WorkflowStep.auto_advance_requires_signal` already exists on the public
|
||||
frontend HTTP type. The derived `KanbanState.steps` item adds the same optional
|
||||
boolean so older or partial payloads continue to behave as ungated steps.
|
||||
|
||||
Every projection from `WorkflowSnapshot.steps` or workflow-step WebSocket
|
||||
payloads into `KanbanState.steps` copies the field without defaulting it. The
|
||||
visibility rule treats only the literal value `true` as signal-gated.
|
||||
|
||||
## Control flow
|
||||
|
||||
1. Initial hydration, snapshot refresh, workspace switching, or a live
|
||||
workflow-step event writes the step events and signal-gated flag into the
|
||||
Kanban store.
|
||||
2. `useNextWorkflowStep` locates the current and adjacent next step.
|
||||
3. It inspects current-step `on_turn_complete` actions for `move_to_next`,
|
||||
`move_to_previous`, or `move_to_step`.
|
||||
4. An ungated move suppresses the composer action because the turn completion
|
||||
can perform the move. A signal-gated `move_to_next` action does not suppress
|
||||
it because a bare halt cannot perform the move. Signal-gated
|
||||
`move_to_previous` and `move_to_step` actions remain suppressed because the
|
||||
adjacent-next-step control would submit the wrong destination.
|
||||
5. The standard and passthrough composer surfaces show the existing action only
|
||||
when the shared projection returns a next-step name and the agent is idle.
|
||||
6. Selecting the action uses the existing manual task-move request and its
|
||||
existing error handling.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
- Missing task, workflow, current step, or next step data produces no action.
|
||||
- An omitted signal-gated field preserves the legacy ungated behavior.
|
||||
- A busy agent keeps the action hidden. Returning to idle recomputes the surface
|
||||
without a reload.
|
||||
- A rejected manual move keeps the task on the current step and uses the current
|
||||
localized error toast.
|
||||
- No completion signal is synthesized, so this path cannot increment the future
|
||||
manual-fallback counter or bypass clarification barriers.
|
||||
|
||||
## Persistence
|
||||
|
||||
No schema or persisted value changes. The design preserves an existing workflow
|
||||
step field in client projections that previously dropped it.
|
||||
|
||||
## Security
|
||||
|
||||
No authorization boundary changes. Manual moves continue through the existing
|
||||
task-move API and backend policy checks.
|
||||
|
||||
## Observability
|
||||
|
||||
No new production metric is required for visibility. Unit tests cover all
|
||||
client projection paths, the gated versus ungated decision, and the unsupported
|
||||
gated move destinations. The boot mapper has a regression test for direct
|
||||
task-page hydration. A browser test proves the user-visible action after an
|
||||
idle signal-gated turn and waits for the causal backend move before asserting
|
||||
the stepper.
|
||||
|
||||
## Responsive behavior
|
||||
|
||||
The standard and passthrough composer surfaces share the state policy. The
|
||||
phone task drawer continues to expose its existing step-move control, so no new
|
||||
compressed desktop control, touch target, breakpoint, or scroll behavior is
|
||||
introduced.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [ADR 0015: Explicit Completion Signal for Auto-Advance](../../../decisions/0015-explicit-completion-signal-for-auto-advance.md)
|
||||
Reference in New Issue
Block a user