fix: preserve signal-gated workflow proceed action (#3572)

This commit is contained in:
Carlos Florêncio
2026-09-10 15:00:09 +01:00
committed by GitHub
parent be19edfa4d
commit 3973825f66
24 changed files with 821 additions and 25 deletions
@@ -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,
+1
View File
@@ -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;
+13
View File
@@ -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],
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -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;
+6 -2
View File
@@ -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: {
+1
View File
@@ -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,
});
});
+1
View File
@@ -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.
@@ -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.
+1
View File
@@ -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)