fix: preserve queued turn state during workflow transitions (#3542)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<void> {
|
||||
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();
|
||||
}
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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<void> {
|
||||
await expect(locator).toBeVisible();
|
||||
const box = await locator.boundingBox();
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user