diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index cc785229ca..482627403b 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -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, diff --git a/apps/backend/internal/backendapp/boot_state_routes_test.go b/apps/backend/internal/backendapp/boot_state_routes_test.go index 4a37bacf9b..41338e3f62 100644 --- a/apps/backend/internal/backendapp/boot_state_routes_test.go +++ b/apps/backend/internal/backendapp/boot_state_routes_test.go @@ -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 diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-helpers.test.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-helpers.test.ts new file mode 100644 index 0000000000..f24d7bfa82 --- /dev/null +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-helpers.test.ts @@ -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, + }); + }); +}); diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-helpers.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-helpers.ts index b016afebcb..0d87c6a028 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-helpers.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-helpers.ts @@ -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, diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 3725ae3f9a..31f9af82e8 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -1267,6 +1267,7 @@ export class ApiClient { on_budget_alert?: Array<{ type: string; config?: Record }>; on_agent_error?: Array<{ type: string; config?: Record }>; }; + auto_advance_requires_signal?: boolean; wip_limit?: number; pull_from_step_id?: string | null; cancel_triggers_turn_complete?: boolean; diff --git a/apps/web/e2e/tests/workflow/workflow-step-proceed.spec.ts b/apps/web/e2e/tests/workflow/workflow-step-proceed.spec.ts index c95a3e824f..b7cae6fb5b 100644 --- a/apps/web/e2e/tests/workflow/workflow-step-proceed.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-step-proceed.spec.ts @@ -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. diff --git a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.signal-gated.test.ts b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.signal-gated.test.ts new file mode 100644 index 0000000000..ef4205b3ea --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.signal-gated.test.ts @@ -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; 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, + }); + }); +}); diff --git a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts index 2a8b785051..d4268e47af 100644 --- a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts +++ b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts @@ -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, diff --git a/apps/web/hooks/domains/kanban/use-plan-actions.test.ts b/apps/web/hooks/domains/kanban/use-plan-actions.test.ts index ba683a2955..f1af8d9056 100644 --- a/apps/web/hooks/domains/kanban/use-plan-actions.test.ts +++ b/apps/web/hooks/domains/kanban/use-plan-actions.test.ts @@ -16,6 +16,9 @@ const mockAppState = vi.hoisted(() => ({ setTaskPlan: vi.fn(), } as Record, })); +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 }, diff --git a/apps/web/hooks/domains/kanban/use-plan-actions.ts b/apps/web/hooks/domains/kanban/use-plan-actions.ts index ee637e112c..090543f51f 100644 --- a/apps/web/hooks/domains/kanban/use-plan-actions.ts +++ b/apps/web/hooks/domains/kanban/use-plan-actions.ts @@ -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; diff --git a/apps/web/lib/ssr/mapper.test.ts b/apps/web/lib/ssr/mapper.test.ts index 2594095bb1..054faf20a4 100644 --- a/apps/web/lib/ssr/mapper.test.ts +++ b/apps/web/lib/ssr/mapper.test.ts @@ -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], diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts index 1c85343ca9..803014b6b9 100644 --- a/apps/web/lib/ssr/mapper.ts +++ b/apps/web/lib/ssr/mapper.ts @@ -139,6 +139,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial { 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, diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index d5acfbc5e4..4bf472aea8 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -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; diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index ecef25d4c1..90293ddc61 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -62,6 +62,7 @@ export type KanbanUpdatePayload = { on_turn_complete?: Array<{ type: string; config?: Record }>; }; 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; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index c0672067bb..d29b097433 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -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; diff --git a/apps/web/lib/ws/handlers/kanban.test.ts b/apps/web/lib/ws/handlers/kanban.test.ts index 24dc5224f8..4233017ad6 100644 --- a/apps/web/lib/ws/handlers/kanban.test.ts +++ b/apps/web/lib/ws/handlers/kanban.test.ts @@ -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: { diff --git a/apps/web/lib/ws/handlers/kanban.ts b/apps/web/lib/ws/handlers/kanban.ts index c1f193f5a9..227630f7f8 100644 --- a/apps/web/lib/ws/handlers/kanban.ts +++ b/apps/web/lib/ws/handlers/kanban.ts @@ -141,6 +141,7 @@ export function registerKanbanHandlers(store: StoreApi): 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, })); diff --git a/apps/web/lib/ws/handlers/workflows.test.ts b/apps/web/lib/ws/handlers/workflows.test.ts index 276b06a651..b5c3181f3b 100644 --- a/apps/web/lib/ws/handlers/workflows.test.ts +++ b/apps/web/lib/ws/handlers/workflows.test.ts @@ -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, }); }); diff --git a/apps/web/lib/ws/handlers/workflows.ts b/apps/web/lib/ws/handlers/workflows.ts index 311c51774c..bf548c6e3a 100644 --- a/apps/web/lib/ws/handlers/workflows.ts +++ b/apps/web/lib/ws/handlers/workflows.ts @@ -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, diff --git a/docs/plans/signal-gated-workflow-proceed-control/plan.md b/docs/plans/signal-gated-workflow-proceed-control/plan.md new file mode 100644 index 0000000000..abb253abc6 --- /dev/null +++ b/docs/plans/signal-gated-workflow-proceed-control/plan.md @@ -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. diff --git a/docs/plans/signal-gated-workflow-proceed-control/task-01-preserve-signal-gated-proceed-visibility.md b/docs/plans/signal-gated-workflow-proceed-control/task-01-preserve-signal-gated-proceed-visibility.md new file mode 100644 index 0000000000..611f3991c8 --- /dev/null +++ b/docs/plans/signal-gated-workflow-proceed-control/task-01-preserve-signal-gated-proceed-visibility.md @@ -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. diff --git a/docs/specs/tasks/README.md b/docs/specs/tasks/README.md index b2d7d69404..b33666ce9b 100644 --- a/docs/specs/tasks/README.md +++ b/docs/specs/tasks/README.md @@ -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) diff --git a/docs/specs/tasks/requirements/workflow-explicit-completion-signal.md b/docs/specs/tasks/requirements/workflow-explicit-completion-signal.md index abfe611fa1..c1592cd3b2 100644 --- a/docs/specs/tasks/requirements/workflow-explicit-completion-signal.md +++ b/docs/specs/tasks/requirements/workflow-explicit-completion-signal.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. diff --git a/docs/specs/tasks/system-design/workflow-signal-gated-manual-move-visibility.md b/docs/specs/tasks/system-design/workflow-signal-gated-manual-move-visibility.md new file mode 100644 index 0000000000..3737fab5d5 --- /dev/null +++ b/docs/specs/tasks/system-design/workflow-signal-gated-manual-move-visibility.md @@ -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)