diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index e9269c9cb7..05803341eb 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -535,6 +535,8 @@ func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID s if !ok { return } + // The legacy engine-less path settles before dispatch. The engine-backed + // path preserves an admitted turn's RUNNING state in its transition hook. s.setSessionWaitingForInput(ctx, taskID, effectiveSession.ID) } } @@ -6145,7 +6147,11 @@ func (s *Service) applyEngineTransitionWithCommitMode( if !ok { return false } - s.setSessionWaitingForInput(ctx, taskID, effectiveSession.ID) + // A queued prompt has already claimed RUNNING before its turn-start + // hook. Preserve that claim when profile preparation keeps the session. + if effectiveSession.ID != session.ID || session.State != models.TaskSessionStateRunning { + s.setSessionWaitingForInput(ctx, taskID, effectiveSession.ID) + } return true } diff --git a/apps/backend/internal/orchestrator/queue_send_now_workflow_state_test.go b/apps/backend/internal/orchestrator/queue_send_now_workflow_state_test.go new file mode 100644 index 0000000000..3344fdf963 --- /dev/null +++ b/apps/backend/internal/orchestrator/queue_send_now_workflow_state_test.go @@ -0,0 +1,93 @@ +package orchestrator + +import ( + "context" + "encoding/json" + "testing" + + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" + "github.com/stretchr/testify/require" +) + +// @covers AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.5 +func TestSendNowWorkflowTransitionPreservesRunningState(t *testing.T) { + for _, delivery := range []string{"send_now", "fifo"} { + t.Run(delivery, func(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + steps, ids := buildWorkflowFromJSON(t, developmentWorkflowJSON) + seedSession(t, repo, "task", "session", ids["Done"]) + seedExecutorRunning(t, repo, "session", "task", "exec") + setSessionState(t, ctx, repo, "session", models.TaskSessionStateWaitingForInput) + eventBus := &recordingEventBus{} + agent := &mockAgentManager{isAgentRunning: true, repoForExecutionLookup: repo} + svc := createEngineService(t, repo, steps, agent) + svc.eventBus = eventBus + svc.taskRepo = newTaskServiceStateRepository(repo, eventBus) + svc.turnService = &repoTurnService{repo: repo} + svc.messageCreator = &mockMessageCreator{} + // Inspect state at provider dispatch, before any completion can heal it. + agent.promptAgentFunc = func(ctx context.Context, _ string, prompt string, _ []v1.MessageAttachment, _ bool) (*executor.PromptResult, error) { + session, err := repo.GetTaskSession(ctx, "session") + require.NoError(t, err) + require.Equal(t, models.TaskSessionStateRunning, session.State) + task, err := repo.GetTask(ctx, "task") + require.NoError(t, err) + require.Equal(t, ids["In Progress"], task.WorkflowStepID) + require.Equal(t, v1.TaskStateInProgress, task.State) + turn, err := svc.turnService.GetActiveTurn(ctx, "session") + require.NoError(t, err) + require.NotNil(t, turn) + require.Equal(t, "continue", prompt) + for _, published := range eventBus.events { + data, err := json.Marshal(published.event.Data) + require.NoError(t, err) + var state struct { + NewState string `json:"new_state"` + } + require.NoError(t, json.Unmarshal(data, &state)) + require.NotEqual(t, "WAITING_FOR_INPUT", state.NewState) + require.NotEqual(t, "REVIEW", state.NewState) + } + return &executor.PromptResult{StopReason: "dispatched"}, nil + } + queued, err := svc.messageQueue.QueueMessage(ctx, "session", "task", "continue", "", messagequeue.QueuedByUser, false, nil) + require.NoError(t, err) + if delivery == "send_now" { + claim, err := svc.messageQueue.ClaimSendNow(ctx, "session", []messagequeue.QueuedMessage{*queued}) + require.NoError(t, err) + svc.markQueuedDispatchInFlight("session", claim.Dispatch.ID) + svc.executeSendNowClaim(claim) + } else { + var taken bool + queued, taken = svc.messageQueue.TakeQueued(ctx, "session") + require.True(t, taken) + require.NotNil(t, queued) + svc.executeQueuedMessage("session", queued) + } + require.Len(t, agent.capturedPrompts, 1) + require.Len(t, svc.messageCreator.(*mockMessageCreator).userMessages, 1) + }) + } +} + +func TestOnTurnStartBeforeAdmissionKeepsSessionPromptable(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + steps, ids := buildWorkflowFromJSON(t, developmentWorkflowJSON) + seedSession(t, repo, "task", "session", ids["Done"]) + setSessionState(t, ctx, repo, "session", models.TaskSessionStateWaitingForInput) + svc := createEngineService(t, repo, steps, &mockAgentManager{repoForExecutionLookup: repo}) + result, err := svc.ProcessOnTurnStart(ctx, "task", "session") + require.NoError(t, err) + require.False(t, result.Queued) + session, err := repo.GetTaskSession(ctx, "session") + require.NoError(t, err) + require.Equal(t, models.TaskSessionStateWaitingForInput, session.State) + task, err := repo.GetTask(ctx, "task") + require.NoError(t, err) + require.Equal(t, ids["In Progress"], task.WorkflowStepID) +} diff --git a/apps/backend/internal/orchestrator/workflow_e2e_test.go b/apps/backend/internal/orchestrator/workflow_e2e_test.go index 4b592cd331..3cfb129d6e 100644 --- a/apps/backend/internal/orchestrator/workflow_e2e_test.go +++ b/apps/backend/internal/orchestrator/workflow_e2e_test.go @@ -132,7 +132,7 @@ var workflowTestCases = []workflowTestCase{ ExpectTransitioned: true, ExpectQueued: false, ExpectResets: 1}, // Agent starts at New Context → on_turn_start → back to In Progress {Trigger: engine.TriggerOnTurnStart, SetRunning: true, ExpectStep: "In Progress", - ExpectTransitioned: true, ExpectQueued: false, ExpectResets: 1}, + ExpectTransitioned: true, ExpectQueued: false, ExpectResets: 1, ExpectState: models.TaskSessionStateRunning}, // Agent finishes at In Progress → New Context again (same reset + auto_start path) {Trigger: engine.TriggerOnTurnComplete, SetRunning: true, ExpectStep: "New Context", ExpectTransitioned: true, ExpectQueued: false, ExpectResets: 2}, @@ -144,7 +144,7 @@ var workflowTestCases = []workflowTestCase{ ExpectTransitioned: true, ExpectQueued: false, ExpectResets: 3}, // User sends message at Done → on_turn_start → In Progress {Trigger: engine.TriggerOnTurnStart, SetRunning: true, ExpectStep: "In Progress", - ExpectTransitioned: true, ExpectQueued: false, ExpectResets: 3}, + ExpectTransitioned: true, ExpectQueued: false, ExpectResets: 3, ExpectState: models.TaskSessionStateRunning}, }, }, { diff --git a/apps/web/e2e/tests/chat/message-queue-workflow-helpers.ts b/apps/web/e2e/tests/chat/message-queue-workflow-helpers.ts new file mode 100644 index 0000000000..187e42b8d2 --- /dev/null +++ b/apps/web/e2e/tests/chat/message-queue-workflow-helpers.ts @@ -0,0 +1,109 @@ +import { expect, type Page } from "@playwright/test"; +import type { SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { waitForSessionState } from "../../helpers/session"; +import { SessionPage } from "../../pages/session-page"; +import { SidebarFilterPopoverPage } from "../../pages/sidebar-filter-popover"; + +// @covers AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.5 +// @covers AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.6 +export async function expectSendNowWorkflowRunning( + page: Page, + api: ApiClient, + seed: SeedData, + mobile: boolean, +): Promise { + const workflow = await api.createWorkflow(seed.workspaceId, "Queued turn state"); + const review = await api.createWorkflowStep(workflow.id, "Review", 0, { is_start_step: true }); + const working = await api.createWorkflowStep(workflow.id, "In Progress", 1); + await api.createWorkflowStep(workflow.id, "Done", 2); + const task = await api.createTaskWithAgent( + seed.workspaceId, + "Queued workflow state", + seed.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: workflow.id, + workflow_step_id: review.id, + repository_ids: [seed.repositoryId], + }, + ); + if (!task.session_id) throw new Error("Task did not return a session ID"); + const sessionId = task.session_id; + await page.goto(`/t/${task.id}`); + const session = new SessionPage(page); + await session.waitForLoad(); + await session.waitForChatIdle(); + await waitForSessionState(api, { + taskId: task.id, + sessionId, + expectedState: "WAITING_FOR_INPUT", + message: "queued workflow session should settle before queue setup", + }); + await api.updateWorkflowStep(review.id, { + events: { on_turn_start: [{ type: "move_to_step", config: { step_id: working.id } }] }, + }); + const identity = await api.getQueueSessionIdentity(task.id, sessionId); + await api.setQueueAutoRun(identity, false); + await api.queueMessage( + identity, + [ + 'e2e:message("replacement is active")', + // Flush the text buffer before holding the mock turn open. + 'e2e:tool_use("Check", {})', + 'e2e:tool_result("checked")', + "e2e:delay(15000)", + 'e2e:message("replacement finished")', + ].join("\n"), + ); + const chat = session.activeChat(); + const chip = chat.getByTestId("queue-chip"); + if (mobile) await chip.tap(); + else await chip.click(); + const row = chat.getByTestId("queue-entry"); + await expect(row).toHaveCount(1); + if (!mobile) await row.hover(); + const sendNow = row.getByTestId("queue-entry-send-now"); + if (mobile) await sendNow.tap(); + else await sendNow.click(); + + const agentBodies = chat.locator("[data-agent-message-body][data-message-id]"); + await expect(agentBodies.filter({ hasText: "replacement is active" })).toBeVisible(); + await waitForSessionState(api, { + taskId: task.id, + sessionId, + expectedState: "RUNNING", + message: "Send Now should keep the queued workflow turn running", + }); + const runningTask = await api.getTask(task.id); + expect(runningTask.state).toBe("IN_PROGRESS"); + expect(runningTask.workflow_step_id).toBe(working.id); + await expect(session.cancelAgentButton()).toBeVisible(); + + if (mobile) await page.getByTestId("mobile-session-menu").tap(); + const surface = mobile + ? page.getByRole("dialog", { name: "Tasks", exact: true }) + : session.sidebar; + const filters = new SidebarFilterPopoverPage(page); + if (mobile) await surface.getByTestId("sidebar-filter-gear").tap(); + else await filters.open(); + await filters.setGroup("State"); + await filters.close(); + await expect( + surface.locator('[data-testid="sidebar-group-header"][data-group-key="IN_PROGRESS"]'), + ).toBeVisible(); + await expect( + surface.locator(`[data-task-row-id="${task.id}"]`).getByTestId("task-state-running"), + ).toBeVisible(); + if (mobile) await page.keyboard.press("Escape"); + + await waitForSessionState(api, { + taskId: task.id, + sessionId, + expectedState: "WAITING_FOR_INPUT", + message: "queued workflow turn should settle after its replacement prompt", + }); + await expect(agentBodies.filter({ hasText: "replacement finished" })).toBeVisible(); + await expect.poll(async () => (await api.getTask(task.id)).state).toBe("REVIEW"); + await expect(session.cancelAgentButton()).toBeHidden(); +} diff --git a/apps/web/e2e/tests/chat/message-queue.spec.ts b/apps/web/e2e/tests/chat/message-queue.spec.ts index 54a54eb843..8de6c6a820 100644 --- a/apps/web/e2e/tests/chat/message-queue.spec.ts +++ b/apps/web/e2e/tests/chat/message-queue.spec.ts @@ -7,6 +7,7 @@ import { SessionPage } from "../../pages/session-page"; import { seedRunningGeneratingSession } from "../../helpers/generating-session"; import { expectFullQueueScrolls, seedFullQueueTask } from "./message-queue-scroll-helpers"; import { waitForQuickChatComposerReady } from "./quick-chat-helpers"; +import { expectSendNowWorkflowRunning } from "./message-queue-workflow-helpers"; import { registerSeparateQueueRows, requestMessageQueueSettings, @@ -14,6 +15,10 @@ import { registerSeparateQueueRows(test); +test("Send Now keeps a workflow transition running", async ({ testPage, apiClient, seedData }) => { + await expectSendNowWorkflowRunning(testPage, apiClient, seedData, false); +}); + // --------------------------------------------------------------------------- // Quick Chat queue tests // --------------------------------------------------------------------------- diff --git a/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts b/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts index 5248647338..60bdb1835a 100644 --- a/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts @@ -8,9 +8,18 @@ import { expectFullQueueScrolls, seedFullQueueTask } from "./message-queue-scrol import { registerSeparateQueueRows } from "../../helpers/message-queue-settings"; import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; import { waitForActiveSessionForegroundActivity } from "../../helpers/session-store"; +import { expectSendNowWorkflowRunning } from "./message-queue-workflow-helpers"; registerSeparateQueueRows(test); +test("mobile Send Now keeps a workflow transition running", async ({ + testPage, + apiClient, + seedData, +}) => { + await expectSendNowWorkflowRunning(testPage, apiClient, seedData, true); +}); + async function expectTouchTarget(locator: Locator): Promise { await expect(locator).toBeVisible(); const box = await locator.boundingBox(); diff --git a/docs/plans/queued-turn-start-state/plan.md b/docs/plans/queued-turn-start-state/plan.md new file mode 100644 index 0000000000..003380f8da --- /dev/null +++ b/docs/plans/queued-turn-start-state/plan.md @@ -0,0 +1,162 @@ +--- +created: 2026-09-09 +status: complete +requirements: + - REQ-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001 +system_design: + - ../../specs/tasks/system-design/runtime-state-publication-order.md +legacy_specs: [] +--- + +# Implementation Plan: Queued turn-start state + +## Overview + +Keep an admitted queued turn running when its start moves the workflow step. +One work order repairs the backend lifecycle and verifies the visible result. +This is conformance with the existing runtime-state contract, especially +AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.5. Requirements need no change. + +## Confirmed cause and evidence + +The reported task is `9f724e85-e643-4ee8-a60b-24efdb98f01b`; the affected +session is `8bce4cee-e40c-44f5-817b-c8e5ee48c925`. +Retained backend evidence was read from +`/root/.kandev/logs/backend-logs-2026-09-09-000007.log`, lines 66351–66417. +Times below are UTC on 2026-09-09; retained log timestamps use UTC+01:00. + +| Time | Backend event | +| --- | --- | +| 08:23:53.227 | Send Now claims one queued message. | +| 08:23:53.240 | The session is published as RUNNING. | +| 08:23:53.244 | Turn `42f8517d-aee0-435e-a22d-b9b5ac5c65f2` starts. | +| 08:23:53.254 | The workflow evaluates an on_turn_start transition. | +| 08:23:53.264 | Task update publishes the destination step and RUNNING session. | +| 08:23:53.267 | The session changes from RUNNING to WAITING_FOR_INPUT. | +| 08:23:53.280 | Task state changes to REVIEW, retaining the destination step. | +| 08:23:53.281 | The executor sends the queued prompt to the agent. | + +`promptSendNowClaim` calls `promptTask` with an `afterClaim` callback. +Prompt admission first sets the session running and creates its turn. +The callback then calls `processOnTurnStartViaEngine`. +`applyEngineTransitionWithCommitMode`, in `transitionLifecycleOnTurnStart` +mode, unconditionally calls `setSessionWaitingForInput` after profile +preparation. That helper also reconciles the task to Review. Prompt dispatch +continues afterward without restoring the admitted session state. + +The browser receives and applies these server events. The earlier primary +session mismatch briefly delays card updates, but the primary session is +correct before the final reversal. No delayed cancellation event is needed +to explain the defect. Metadata-only state events (`newState=-`) are separate. + +The existing `workflow_e2e_test.go` table includes running on_turn_start +transitions but omits `ExpectState` on those rows. + +## Scope + +### In scope + +- Preserve current-turn ownership and running state across a successful + same-session on_turn_start transition. +- Verify Send Now and the ordinary queued-dispatch caller of the same hook. +- Preserve pre-admission transitions, profile preparation, WIP admission, + failure cleanup, and legitimate end-of-turn settlement. +- Prove desktop and mobile receive the same corrected authoritative state. + +### Out of scope + +- Client-side state overrides, new state values, and changes to grouping. +- Queue ordering, cancellation policy, schema changes, and Office ownership. +- Changes to the reported live task or its agent process. + +## Technical approach + +Repair the on_turn_start lifecycle branch in +`apps/backend/internal/orchestrator/event_handlers_workflow.go`. +Distinguish a transition for an already-admitted turn from preparation for a +future prompt. Preserve the admitted turn when the effective session remains +the same. Keep profile-switch and failure behavior explicit; do not remove +all waiting-state writes across lifecycle modes. + +The implementation uses the session state and effective session ID already +available in the lifecycle branch. No new context parameter or turn registry +is necessary. The per-session cancellation guard and queue claim order remain +unchanged. The hook does not write RUNNING afterward, so it cannot revive a +cancelled turn. + +The implementation must not depend on a later activity event healing the state. +The existing task publication order and client freshness design remain valid. + +## Tests + +Add `queue_send_now_workflow_state_test.go` with +`TestSendNowWorkflowTransitionPreservesRunningState`. Use the real queue, +workflow engine, prompt admission, and repository with a controllable executor. +Inspect state synchronously inside the fake provider dispatch, before it +returns. Assert session RUNNING, task IN_PROGRESS, the target workflow step, +an active turn, and one prompt. Capture lifecycle publications and reject any +waiting/review reversal before dispatch returns. + +Add paired tests for the pre-admission hook and ordinary FIFO callback. +Retain coverage for profile changes, WIP deferral, failed dispatch, explicit +cancellation, and genuine turn completion. Strengthen the existing workflow +table's on_turn_start rows with state assertions. + +These checks cover AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.1, .2, .5, +and .8. Desktop/mobile evidence covers .6. + +## E2E tests + +Extend `apps/web/e2e/tests/chat/message-queue.spec.ts` with +`Send Now keeps a workflow transition running`. Start from a settled session +with a queued slow prompt and a Review-to-In-Progress on_turn_start action. +Click Send Now and assert the running composer and State-grouped sidebar +before completion, without reloading. Assert legitimate settlement afterward. + +Extend `apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts` with +the same scenario using the existing touch queue control and task switcher. +Shared data semantics are the mobile requirement; layout and navigation stay +within the existing composition. Reuse the existing queue test fixtures. + +## Work orders + +- [x] [Task 01: Preserve admitted turn state](task-01-preserve-admitted-turn.md) + +## Verification results + +The Send Now and FIFO regressions first failed with WAITING_FOR_INPUT instead +of RUNNING at provider dispatch. Both pass after the lifecycle fix. The +pre-admission case and strengthened workflow table also pass. + +The following targeted orchestrator groups passed with `-race -tags fts5`: + +- `Test.*(SendNow|SendQueuedNow|Queued|Workflow|OnTurnStart|PromptTask|Cancel)` +- `Test.*(ApplyEngineTransition|PrepareWorkflowStepSession|SwitchSessionForProfile|WIP|Wip)` +- `Test(SwitchSessionForStep|ProcessOnEnter_ProfileSwitch)` + +Desktop Chromium: two Send Now tests passed. Mobile Chrome: two Send Now tests +passed. These include the new workflow regression and existing priority-order +coverage on each platform. The new cases verify the running composer, State +grouping, authoritative session/task state, and legitimate completion without +a reload. + +The initial browser failures exposed test-fixture issues: buffered mock text +needed a tool boundary, and the mobile filter trigger needed drawer scope. +The corrected fixtures pass. Focused ESLint and Prettier checks also pass. + +Managed E2E runs used disposable containers and mock agents. The first run +built the artifacts. Later runs reused those artifacts with `--no-build`. +The runners removed their containers after each run. The reported live task, +session, and runtime were not changed. + +Public docs need no change. This fix restores the behavior already described +in `docs/public/sessions-and-review.md` and the runtime-state specification. + +## Risks + +- The shared hook runs both before and after prompt admission; a blanket change + could make a prepared session busy without a dispatched prompt. +- Profile switching can replace the effective session. Never transfer the old + session's running state to an unstarted replacement. +- WIP deferral and genuine cancellation must still prevent dispatch or settle + the correct turn. diff --git a/docs/plans/queued-turn-start-state/task-01-preserve-admitted-turn.md b/docs/plans/queued-turn-start-state/task-01-preserve-admitted-turn.md new file mode 100644 index 0000000000..6d0fa4b51d --- /dev/null +++ b/docs/plans/queued-turn-start-state/task-01-preserve-admitted-turn.md @@ -0,0 +1,135 @@ +--- +id: "01-preserve-admitted-turn" +title: "Preserve admitted turn state" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001 +acceptance_criteria: + - AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.1 + - AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.2 + - AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.5 + - AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.6 + - AC-TASKS-RUNTIME-STATE-PUBLICATION-ORDER-001.8 +system_design: + - ../../specs/tasks/system-design/runtime-state-publication-order.md +--- + +# Task 01: Preserve admitted turn state + +## Summary + +Repair the successful on_turn_start lifecycle for an already-admitted queued +turn. Keep the task and session projections consistent while that turn runs. + +## In scope + +- Write the failing `TestSendNowWorkflowTransitionPreservesRunningState` + regression before changing production code. +- Exercise the real Send Now claim, prompt admission, workflow transition, + repository writes, and lifecycle publication path. Inspect state inside + the fake executor before its prompt call returns. +- Add the corresponding ordinary FIFO and pre-admission transition cases. +- Correct the shared transition branch with existing turn/session ownership. +- Add desktop and mobile queue regressions described in the plan. +- Verify profile switching, WIP deferral, cancellation, failed prompts, and + real completion retain their existing behavior. + +## Out of scope + +Frontend production changes, queue policy changes, persistence migrations, +Office state ownership, and live-instance intervention. + +## Acceptance + +1. Same-session Send Now and FIFO transitions keep the admitted turn RUNNING + and task IN_PROGRESS, with one dispatched prompt and no false idle events. +2. Preparation without an admitted turn, changed profiles, WIP deferral, and + real settlement remain correct. The fix preserves claim serialization. +3. Desktop State grouping and the mobile task surface show active work without + a reload; genuine completion restores the expected idle state. + +## Verification + +Run the new backend regression first and record its expected state failure. +Then implement and run the focused package cases from `apps/backend`: + +```bash +rtk go test -tags fts5 ./internal/orchestrator -run 'TestSendNowWorkflowTransitionPreservesRunningState' -count=1 +rtk go test -race -tags fts5 ./internal/orchestrator -run 'Test.*(SendNow|SendQueuedNow|Queued|Workflow|OnTurnStart|PromptTask|Cancel)' -count=1 +``` + +Before the first pnpm command in a fresh worktree, run from `apps`: + +```bash +rtk pnpm install --frozen-lockfile +``` + +Run sequentially from `apps/web`; each managed run builds its artifacts: + +```bash +rtk pnpm e2e:run --project chromium tests/chat/message-queue.spec.ts -- --grep 'Send Now' +rtk pnpm e2e:run --project mobile-chrome tests/chat/mobile-message-queue-management.spec.ts -- --grep 'Send Now' +``` + +Use the same test title fragment for the added mobile scenario. Confirm test +discovery and record command results. Do not run against the user's instance. + +## Files likely touched + +- `apps/backend/internal/orchestrator/event_handlers_workflow.go` +- `apps/backend/internal/orchestrator/queue_send_now.go` (only if context is needed) +- `apps/backend/internal/orchestrator/event_handlers_agent.go` (paired FIFO path) +- `apps/backend/internal/orchestrator/task_operations.go` (admission context only) +- `apps/backend/internal/orchestrator/queue_send_now_workflow_state_test.go` +- `apps/backend/internal/orchestrator/workflow_e2e_test.go` +- `apps/web/e2e/tests/chat/message-queue.spec.ts` +- `apps/web/e2e/tests/chat/mobile-message-queue-management.spec.ts` + +## Dependencies + +None. + +## Risks + +An unconditional removal of idle settlement can break replacement-session +preparation. An unconditional post-hook RUNNING write can revive a cancelled +or superseded turn. Preserve ownership instead of repairing state afterward. + +## Parallelism + +`sequential` + +## Inputs + +- [Confirmed trace and repair approach](plan.md). +- [Runtime-state requirements](../../specs/tasks/requirements/runtime-state-publication-order.md). +- [Runtime-state design](../../specs/tasks/system-design/runtime-state-publication-order.md). +- `queue_send_now_test.go`: existing real queue claim and executor fixture. +- `workflow_e2e_test.go`: existing workflow engine fixture and turn-start rows. +- Read scoped backend/web guidance and the TDD/E2E skills before implementation. + +## Results + +Implemented in the successful on_turn_start branch. A same-session transition +preserves an admitted RUNNING state. Pre-admission and replacement-session +preparation retain their waiting-state behavior. + +The Send Now and FIFO tests failed before the fix and pass afterward. Their +fake provider inspects state synchronously at dispatch, so no channel or +timing-dependent pause is necessary. The tests also reject false idle events +and duplicate prompts or user messages. + +The focused race-enabled groups passed, including prompt cancellation, +workflow transitions, profile changes, and WIP handling. Desktop and mobile +Send Now suites each passed both selected tests. Both new browser cases +verify active state without a reload and normal settlement after completion. +Focused ESLint and Prettier checks passed. + +The shared browser fixture is +`apps/web/e2e/tests/chat/message-queue-workflow-helpers.ts`. It flushes mock +text at a tool boundary and scopes mobile navigation to the Tasks drawer. +No frontend production changes or live-instance mutations were necessary. +See [verification details](plan.md#verification-results).