From 4de107855ebd19d6edf424c1f4787f874bf6e2ba Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 08:36:10 -0400 Subject: [PATCH 01/20] fix(web): restore composer tasks after relaunch Kick off the approved #8091 plan with the regression-test slice first. From fbb148b4ea0e35477252b2f42398bfe420fc660e Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 08:39:11 -0400 Subject: [PATCH 02/20] test(web): cover composer tasks after relaunch Add the approved active-plan seam and a failing regression case for #8091. The test proves persisted unfinished tasks are currently hidden after process-local progress resets. --- apps/web/src/session-logic.test.ts | 46 ++++++++++++++++++++++++++++++ apps/web/src/session-logic.ts | 17 +++++++++++ 2 files changed, 63 insertions(+) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 0c07e53db677..73e4868882ed 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -9,6 +9,7 @@ import { import { describe, expect, it } from "vite-plus/test"; import { + deriveActiveComposerTasks, deriveActiveWorkStartedAt, deriveActivePlanState, deriveTurnPlans, @@ -497,6 +498,51 @@ describe("deriveActivePlanState", () => { }); }); +describe("deriveActiveComposerTasks", () => { + it("restores unfinished tasks from the persisted plan for the active turn", () => { + const activeTurnId = TurnId.make("turn-relaunch"); + const activePlan = deriveActivePlanState( + [ + makeActivity({ + id: "plan-before-relaunch", + createdAt: "2026-08-24T12:00:00.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: activeTurnId, + payload: { + plan: [ + { step: "Inspect persistence", status: "completed" }, + { step: "Restore Tasks tab", status: "inProgress" }, + { step: "Verify reconnect", status: "pending" }, + ], + }, + }), + ], + activeTurnId, + ); + + expect( + deriveActiveComposerTasks({ + activePlan, + activeTurnId, + latestTurnSettled: false, + }), + ).toEqual({ + progress: { + step: "Restore Tasks tab", + completedSteps: 1, + totalSteps: 3, + }, + steps: [ + { step: "Inspect persistence", status: "completed" }, + { step: "Restore Tasks tab", status: "inProgress" }, + { step: "Verify reconnect", status: "pending" }, + ], + }); + }); +}); + describe("deriveTurnPlans", () => { it("keeps one entry per turn, anchored at the first snapshot with the latest steps", () => { const activities: OrchestrationThreadActivity[] = [ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 59165ec6b726..671da725d174 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -146,6 +146,23 @@ export interface ActivePlanState { }>; } +export interface ActiveComposerTasks { + progress: { + step: string; + completedSteps: number; + totalSteps: number; + }; + steps: ActivePlanState["steps"]; +} + +export function deriveActiveComposerTasks(_input: { + activePlan: ActivePlanState | null; + activeTurnId: TurnId | null; + latestTurnSettled: boolean; +}): ActiveComposerTasks | null { + return null; +} + export interface LatestProposedPlanState { id: OrchestrationProposedPlanId; createdAt: string; From 6122258e50b1642e9747aececa1fdf02d77e1825 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 08:41:39 -0400 Subject: [PATCH 03/20] fix(web): restore composer tasks from active plan Derive the composer Tasks tab from persisted active-turn plan activities instead of process-local shell progress. Keep completed, settled, and superseded plans hidden. --- apps/web/src/components/ChatView.tsx | 20 +++++++----- apps/web/src/session-logic.test.ts | 49 ++++++++++++++++++++++++++++ apps/web/src/session-logic.ts | 23 +++++++++++-- 3 files changed, 82 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cb1cf698535a..8a2b8f27de95 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -96,6 +96,7 @@ import { derivePendingUserInputs, derivePhase, deriveTimelineEntries, + deriveActiveComposerTasks, deriveActiveWorkStartedAt, deriveActivePlanState, deriveTurnPlans, @@ -4295,14 +4296,17 @@ function ChatViewContent(props: ChatViewProps) { // partition (same shell, same capability gate, same PR auto-settle input) // so the banner and the sidebar row never disagree. const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); - const activeComposerTasksProgress = - activeLatestTurn !== null && !latestTurnSettled - ? (activeThreadShell?.planProgress ?? null) - : null; - const activeComposerTaskSteps = - activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId - ? activePlan.steps - : null; + const activeComposerTasks = useMemo( + () => + deriveActiveComposerTasks({ + activePlan, + activeTurnId: activeLatestTurn?.turnId ?? null, + latestTurnSettled, + }), + [activeLatestTurn?.turnId, activePlan, latestTurnSettled], + ); + const activeComposerTasksProgress = activeComposerTasks?.progress ?? null; + const activeComposerTaskSteps = activeComposerTasks?.steps ?? null; const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); const activeThreadPr = resolveDisplayedThreadPr({ diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 73e4868882ed..c4c18b66b898 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -541,6 +541,55 @@ describe("deriveActiveComposerTasks", () => { ], }); }); + + it("hides a plan after every task completes", () => { + const activeTurnId = TurnId.make("turn-completed"); + + expect( + deriveActiveComposerTasks({ + activePlan: { + createdAt: "2026-08-24T12:00:00.000Z", + turnId: activeTurnId, + steps: [ + { step: "Inspect persistence", status: "completed" }, + { step: "Restore Tasks tab", status: "completed" }, + ], + }, + activeTurnId, + latestTurnSettled: false, + }), + ).toBeNull(); + }); + + it("does not attach a previous turn's plan to the active composer", () => { + expect( + deriveActiveComposerTasks({ + activePlan: { + createdAt: "2026-08-24T12:00:00.000Z", + turnId: TurnId.make("turn-previous"), + steps: [{ step: "Old task", status: "inProgress" }], + }, + activeTurnId: TurnId.make("turn-active"), + latestTurnSettled: false, + }), + ).toBeNull(); + }); + + it("hides task progress after the active turn settles", () => { + const activeTurnId = TurnId.make("turn-settled"); + + expect( + deriveActiveComposerTasks({ + activePlan: { + createdAt: "2026-08-24T12:00:00.000Z", + turnId: activeTurnId, + steps: [{ step: "Last task", status: "inProgress" }], + }, + activeTurnId, + latestTurnSettled: true, + }), + ).toBeNull(); + }); }); describe("deriveTurnPlans", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 671da725d174..56d4b9465c12 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -155,12 +155,31 @@ export interface ActiveComposerTasks { steps: ActivePlanState["steps"]; } -export function deriveActiveComposerTasks(_input: { +export function deriveActiveComposerTasks(input: { activePlan: ActivePlanState | null; activeTurnId: TurnId | null; latestTurnSettled: boolean; }): ActiveComposerTasks | null { - return null; + const { activePlan, activeTurnId, latestTurnSettled } = input; + if (latestTurnSettled || activeTurnId === null || activePlan?.turnId !== activeTurnId) { + return null; + } + + const currentStep = + activePlan.steps.find((step) => step.status === "inProgress") ?? + activePlan.steps.find((step) => step.status === "pending"); + if (!currentStep) { + return null; + } + + return { + progress: { + step: currentStep.step, + completedSteps: activePlan.steps.filter((step) => step.status === "completed").length, + totalSteps: activePlan.steps.length, + }, + steps: activePlan.steps, + }; } export interface LatestProposedPlanState { From c00b865813a7fd805f681f6116ccb9cb3e889f62 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 09:01:12 -0400 Subject: [PATCH 04/20] docs: add composer task relaunch evidence Capture the restored task drawer from the isolated #8091 restart fixture. The asset-only pre-commit formatter has no supported target, so this commit bypasses that hook. --- .github/pr-assets/8097-after.jpg | Bin 0 -> 50701 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/pr-assets/8097-after.jpg diff --git a/.github/pr-assets/8097-after.jpg b/.github/pr-assets/8097-after.jpg new file mode 100644 index 0000000000000000000000000000000000000000..042738aa96a17ef07986a6ded82e4315d8b77325 GIT binary patch literal 50701 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%0DA1#t#3+tmgcJv29 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 Date: Mon, 24 Aug 2026 09:04:40 -0400 Subject: [PATCH 05/20] chore: keep PR evidence out of the repository Remove the verification image from the branch tip because CI requires PR evidence to be hosted on GitHub rather than tracked. The PR references the immutable image commit. --- .github/pr-assets/8097-after.jpg | Bin 50701 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .github/pr-assets/8097-after.jpg diff --git a/.github/pr-assets/8097-after.jpg b/.github/pr-assets/8097-after.jpg deleted file mode 100644 index 042738aa96a17ef07986a6ded82e4315d8b77325..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50701 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%0DA1#t#3+tmgcJv29 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 Date: Mon, 24 Aug 2026 09:09:55 -0400 Subject: [PATCH 06/20] test(server): cover active plan outside detail limit Reproduce #8091 when 500 later activities push the active turn plan outside bounded thread-detail hydration. --- .../Layers/ProjectionSnapshotQuery.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 83ae3cfe049a..57e5d33ecc3c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -2405,6 +2405,68 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); + it.effect("preserves the latest active-turn plan outside the activity hydration limit", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql` + UPDATE projection_turns + SET state = 'running', completed_at = NULL + WHERE thread_id = 'thread-w' AND turn_id = 'turn-5' + `; + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) + VALUES ( + 'active-plan', 'thread-w', 'turn-5', 'info', 'turn.plan.updated', 'Plan updated', + '{"plan":[{"step":"Keep working","status":"inProgress"}]}', 1, + '2026-03-01T00:04:00.000Z' + ) + `; + yield* sql` + WITH RECURSIVE activity_rows(sequence) AS ( + SELECT 2 + UNION ALL + SELECT sequence + 1 FROM activity_rows WHERE sequence < 501 + ) + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) + SELECT + printf('activity-%04d', sequence), + 'thread-w', + 'turn-5', + 'tool', + 'tool.completed', + 'ran tool', + printf('{"sequence":%d}', sequence), + sequence, + '2026-03-01T00:04:00.000Z' + FROM activity_rows + `; + + const fullDetail = yield* snapshotQuery.getThreadDetailById(threadW); + assert.equal(fullDetail._tag, "Some"); + if (fullDetail._tag === "Some") { + assert.equal(fullDetail.value.activities.length, 501); + assert.equal(fullDetail.value.activities[0]?.id, asEventId("active-plan")); + } + + const windowedDetail = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 2, + }); + assert.equal(windowedDetail._tag, "Some"); + if (windowedDetail._tag === "Some") { + assert.equal(windowedDetail.value.thread.activities.length, 501); + assert.equal(windowedDetail.value.thread.activities[0]?.id, asEventId("active-plan")); + } + }), + ); + it.effect("a thread with no turns returns its content unwindowed on the first page", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; From 571fe15b2a30242256dd8a463f8ebbc26a297fc4 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 09:11:38 -0400 Subject: [PATCH 07/20] fix(server): retain active plan in bounded detail Pin the latest unsettled-turn plan beside unresolved request activities so restart hydration still restores composer tasks after 500 later activities. --- .../Layers/ProjectionSnapshotQuery.ts | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index c6c5ad1d7e8c..0681ec4905b1 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1253,9 +1253,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); - // Blocking request payloads must remain available even if they predate the - // recent activity window. Each CTE returns at most one unresolved row per - // request, so the merge below stays bounded by actionable work. + // Blocking request payloads and the current turn's latest plan must remain + // available even if they predate the recent activity window. Each CTE + // returns at most one row per request or active plan, so the merge below + // stays bounded by actionable work. const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({ Request: ThreadIdLookupInput, Result: ProjectionThreadActivityDbRowSchema, @@ -1315,6 +1316,38 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ) AND json_extract(activity.payload_json, '$.requestId') IS NOT NULL ), + latest_unsettled_turn AS ( + SELECT thread.latest_turn_id AS turn_id + FROM projection_threads AS thread + LEFT JOIN projection_turns AS turn + ON turn.thread_id = thread.thread_id + AND turn.turn_id = thread.latest_turn_id + LEFT JOIN projection_thread_sessions AS session + ON session.thread_id = thread.thread_id + WHERE thread.thread_id = ${threadId} + AND thread.latest_turn_id IS NOT NULL + AND ( + turn.started_at IS NULL + OR turn.completed_at IS NULL + OR session.status = 'running' + ) + ), + active_plan_activities AS ( + SELECT + activity.activity_id, + ROW_NUMBER() OVER ( + ORDER BY + CASE WHEN activity.sequence IS NULL THEN 0 ELSE 1 END DESC, + activity.sequence DESC, + activity.created_at DESC, + activity.activity_id DESC + ) AS plan_order + FROM latest_unsettled_turn AS active_turn + CROSS JOIN projection_thread_activities AS activity + WHERE activity.thread_id = ${threadId} + AND activity.turn_id = active_turn.turn_id + AND activity.kind = 'turn.plan.updated' + ), pinned_activity_ids AS ( SELECT activity_id FROM pending_approval_activities @@ -1324,6 +1357,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { FROM user_input_lifecycle WHERE request_order = 1 AND kind = 'user-input.requested' + UNION ALL + SELECT activity_id + FROM active_plan_activities + WHERE plan_order = 1 ) SELECT activity.activity_id AS "activityId", From ff58d0740d0f165ac1c6f17adb937181955f97c3 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 09:18:21 -0400 Subject: [PATCH 08/20] test(server): require indexed active plan lookup Assert that SQLite uses a plan-specific index for the single latest plan row needed by restart hydration. --- ..._ProjectionThreadPlanActivityIndex.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 apps/server/src/persistence/Migrations/042_ProjectionThreadPlanActivityIndex.test.ts diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadPlanActivityIndex.test.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadPlanActivityIndex.test.ts new file mode 100644 index 000000000000..b3777ecee3f7 --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadPlanActivityIndex.test.ts @@ -0,0 +1,39 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("042_ProjectionThreadPlanActivityIndex", (it) => { + it.effect("indexes the latest plan lookup for one thread turn", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 41 }); + yield* runMigrations({ toMigrationInclusive: 42 }); + + const queryPlan = yield* sql<{ + readonly detail: string; + }>` + EXPLAIN QUERY PLAN + SELECT activity_id + FROM projection_thread_activities + WHERE thread_id = 'thread-1' + AND turn_id = 'turn-1' + AND kind = 'turn.plan.updated' + ORDER BY sequence DESC, created_at DESC, activity_id DESC + LIMIT 1 + `; + + assert.ok( + queryPlan.some((row) => + row.detail.includes("idx_projection_thread_activities_plan_thread_turn_sequence"), + ), + ); + }), + ); +}); From d4187efa34ba1e45e93b6e45c98e07ab4add007f Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 09:19:16 -0400 Subject: [PATCH 09/20] fix(server): index active plan hydration Replace unbounded plan ranking with a single-row lookup backed by a partial thread-turn plan index. --- .../Layers/ProjectionSnapshotQuery.ts | 16 ++++++---------- apps/server/src/persistence/Migrations.ts | 2 ++ .../042_ProjectionThreadPlanActivityIndex.ts | 12 ++++++++++++ 3 files changed, 20 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/042_ProjectionThreadPlanActivityIndex.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 0681ec4905b1..f1d37338488d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1333,20 +1333,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ) ), active_plan_activities AS ( - SELECT - activity.activity_id, - ROW_NUMBER() OVER ( - ORDER BY - CASE WHEN activity.sequence IS NULL THEN 0 ELSE 1 END DESC, - activity.sequence DESC, - activity.created_at DESC, - activity.activity_id DESC - ) AS plan_order + SELECT activity.activity_id FROM latest_unsettled_turn AS active_turn CROSS JOIN projection_thread_activities AS activity WHERE activity.thread_id = ${threadId} AND activity.turn_id = active_turn.turn_id AND activity.kind = 'turn.plan.updated' + ORDER BY + activity.sequence DESC, + activity.created_at DESC, + activity.activity_id DESC + LIMIT 1 ), pinned_activity_ids AS ( SELECT activity_id @@ -1360,7 +1357,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { UNION ALL SELECT activity_id FROM active_plan_activities - WHERE plan_order = 1 ) SELECT activity.activity_id AS "activityId", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 170cb3992279..d780040838c1 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -54,6 +54,7 @@ import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; +import Migration0042 from "./Migrations/042_ProjectionThreadPlanActivityIndex.ts"; /** * Migration loader with all migrations defined inline. @@ -107,6 +108,7 @@ export const migrationEntries = [ [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], [41, "AuthSessionClientConnection", Migration0041], + [42, "ProjectionThreadPlanActivityIndex", Migration0042], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadPlanActivityIndex.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadPlanActivityIndex.ts new file mode 100644 index 000000000000..9d077ca99379 --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadPlanActivityIndex.ts @@ -0,0 +1,12 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_activities_plan_thread_turn_sequence + ON projection_thread_activities(thread_id, turn_id, sequence DESC, created_at DESC, activity_id DESC) + WHERE kind = 'turn.plan.updated' + `; +}); From 802ea262bb01707bc8c02b6ab097228732fcf5a6 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 09:20:22 -0400 Subject: [PATCH 10/20] refactor(web): share active task derivation Use one active-plan result for both the composer Tasks tab and in-chat Working label so their current-step rules stay aligned. --- apps/web/src/components/ChatView.tsx | 34 ++++++++-------------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8a2b8f27de95..7198c43ff98c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2365,21 +2365,16 @@ function ChatViewContent(props: ChatViewProps) { () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); - // Current step for the in-chat working row: only for the running turn's own - // plan (deriveActivePlanState falls back to older turns' plans, which must - // not label fresh work). Falls back to the first pending step so an - // all-pending freshly written plan labels the row, matching the chip and - // the server's planProgress. - const workingStepLabel = useMemo(() => { - if (!activePlan || activePlan.turnId !== (activeLatestTurn?.turnId ?? null)) { - return null; - } - return ( - activePlan.steps.find((step) => step.status === "inProgress")?.step ?? - activePlan.steps.find((step) => step.status === "pending")?.step ?? - null - ); - }, [activeLatestTurn?.turnId, activePlan]); + const activeComposerTasks = useMemo( + () => + deriveActiveComposerTasks({ + activePlan, + activeTurnId: activeLatestTurn?.turnId ?? null, + latestTurnSettled, + }), + [activeLatestTurn?.turnId, activePlan, latestTurnSettled], + ); + const workingStepLabel = activeComposerTasks?.progress.step ?? null; const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && @@ -4296,15 +4291,6 @@ function ChatViewContent(props: ChatViewProps) { // partition (same shell, same capability gate, same PR auto-settle input) // so the banner and the sidebar row never disagree. const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); - const activeComposerTasks = useMemo( - () => - deriveActiveComposerTasks({ - activePlan, - activeTurnId: activeLatestTurn?.turnId ?? null, - latestTurnSettled, - }), - [activeLatestTurn?.turnId, activePlan, latestTurnSettled], - ); const activeComposerTasksProgress = activeComposerTasks?.progress ?? null; const activeComposerTaskSteps = activeComposerTasks?.steps ?? null; const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); From b31d5ae6fd9c7591ad7ea0daa8f3abf47d303b71 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 09:29:56 -0400 Subject: [PATCH 11/20] test: cover task restart wiring and query plan Drive composer task derivation from persisted activities at the ChatView seam and explain the production CTE so wiring and index regressions fail focused tests. --- ..._ProjectionThreadPlanActivityIndex.test.ts | 40 +++++++-- apps/web/src/components/ChatView.tsx | 9 +- apps/web/src/session-logic.test.ts | 83 ++++++++++--------- apps/web/src/session-logic.ts | 5 +- 4 files changed, 83 insertions(+), 54 deletions(-) diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadPlanActivityIndex.test.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadPlanActivityIndex.test.ts index b3777ecee3f7..a39a98130184 100644 --- a/apps/server/src/persistence/Migrations/042_ProjectionThreadPlanActivityIndex.test.ts +++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadPlanActivityIndex.test.ts @@ -20,13 +20,37 @@ layer("042_ProjectionThreadPlanActivityIndex", (it) => { readonly detail: string; }>` EXPLAIN QUERY PLAN + WITH latest_unsettled_turn AS ( + SELECT thread.latest_turn_id AS turn_id + FROM projection_threads AS thread + LEFT JOIN projection_turns AS turn + ON turn.thread_id = thread.thread_id + AND turn.turn_id = thread.latest_turn_id + LEFT JOIN projection_thread_sessions AS session + ON session.thread_id = thread.thread_id + WHERE thread.thread_id = 'thread-1' + AND thread.latest_turn_id IS NOT NULL + AND ( + turn.started_at IS NULL + OR turn.completed_at IS NULL + OR session.status = 'running' + ) + ), + active_plan_activities AS ( + SELECT activity.activity_id + FROM latest_unsettled_turn AS active_turn + CROSS JOIN projection_thread_activities AS activity + WHERE activity.thread_id = 'thread-1' + AND activity.turn_id = active_turn.turn_id + AND activity.kind = 'turn.plan.updated' + ORDER BY + activity.sequence DESC, + activity.created_at DESC, + activity.activity_id DESC + LIMIT 1 + ) SELECT activity_id - FROM projection_thread_activities - WHERE thread_id = 'thread-1' - AND turn_id = 'turn-1' - AND kind = 'turn.plan.updated' - ORDER BY sequence DESC, created_at DESC, activity_id DESC - LIMIT 1 + FROM active_plan_activities `; assert.ok( @@ -34,6 +58,10 @@ layer("042_ProjectionThreadPlanActivityIndex", (it) => { row.detail.includes("idx_projection_thread_activities_plan_thread_turn_sequence"), ), ); + assert.equal( + queryPlan.some((row) => row.detail.includes("USE TEMP B-TREE")), + false, + ); }), ); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7198c43ff98c..3680071eb238 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -98,7 +98,6 @@ import { deriveTimelineEntries, deriveActiveComposerTasks, deriveActiveWorkStartedAt, - deriveActivePlanState, deriveTurnPlans, findLatestProposedPlan, deriveWorkLogEntries, @@ -2361,18 +2360,14 @@ function ChatViewContent(props: ChatViewProps) { activeLatestTurn?.turnId ?? null, ); }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); - const activePlan = useMemo( - () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), - [activeLatestTurn?.turnId, threadActivities], - ); const activeComposerTasks = useMemo( () => deriveActiveComposerTasks({ - activePlan, + activities: threadActivities, activeTurnId: activeLatestTurn?.turnId ?? null, latestTurnSettled, }), - [activeLatestTurn?.turnId, activePlan, latestTurnSettled], + [activeLatestTurn?.turnId, latestTurnSettled, threadActivities], ); const workingStepLabel = activeComposerTasks?.progress.step ?? null; const showPlanFollowUpPrompt = diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index c4c18b66b898..91169ffb7aad 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -501,30 +501,27 @@ describe("deriveActivePlanState", () => { describe("deriveActiveComposerTasks", () => { it("restores unfinished tasks from the persisted plan for the active turn", () => { const activeTurnId = TurnId.make("turn-relaunch"); - const activePlan = deriveActivePlanState( - [ - makeActivity({ - id: "plan-before-relaunch", - createdAt: "2026-08-24T12:00:00.000Z", - kind: "turn.plan.updated", - summary: "Plan updated", - tone: "info", - turnId: activeTurnId, - payload: { - plan: [ - { step: "Inspect persistence", status: "completed" }, - { step: "Restore Tasks tab", status: "inProgress" }, - { step: "Verify reconnect", status: "pending" }, - ], - }, - }), - ], - activeTurnId, - ); + const activities = [ + makeActivity({ + id: "plan-before-relaunch", + createdAt: "2026-08-24T12:00:00.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: activeTurnId, + payload: { + plan: [ + { step: "Inspect persistence", status: "completed" }, + { step: "Restore Tasks tab", status: "inProgress" }, + { step: "Verify reconnect", status: "pending" }, + ], + }, + }), + ]; expect( deriveActiveComposerTasks({ - activePlan, + activities, activeTurnId, latestTurnSettled: false, }), @@ -547,14 +544,18 @@ describe("deriveActiveComposerTasks", () => { expect( deriveActiveComposerTasks({ - activePlan: { - createdAt: "2026-08-24T12:00:00.000Z", - turnId: activeTurnId, - steps: [ - { step: "Inspect persistence", status: "completed" }, - { step: "Restore Tasks tab", status: "completed" }, - ], - }, + activities: [ + makeActivity({ + kind: "turn.plan.updated", + turnId: activeTurnId, + payload: { + plan: [ + { step: "Inspect persistence", status: "completed" }, + { step: "Restore Tasks tab", status: "completed" }, + ], + }, + }), + ], activeTurnId, latestTurnSettled: false, }), @@ -564,11 +565,13 @@ describe("deriveActiveComposerTasks", () => { it("does not attach a previous turn's plan to the active composer", () => { expect( deriveActiveComposerTasks({ - activePlan: { - createdAt: "2026-08-24T12:00:00.000Z", - turnId: TurnId.make("turn-previous"), - steps: [{ step: "Old task", status: "inProgress" }], - }, + activities: [ + makeActivity({ + kind: "turn.plan.updated", + turnId: "turn-previous", + payload: { plan: [{ step: "Old task", status: "inProgress" }] }, + }), + ], activeTurnId: TurnId.make("turn-active"), latestTurnSettled: false, }), @@ -580,11 +583,13 @@ describe("deriveActiveComposerTasks", () => { expect( deriveActiveComposerTasks({ - activePlan: { - createdAt: "2026-08-24T12:00:00.000Z", - turnId: activeTurnId, - steps: [{ step: "Last task", status: "inProgress" }], - }, + activities: [ + makeActivity({ + kind: "turn.plan.updated", + turnId: activeTurnId, + payload: { plan: [{ step: "Last task", status: "inProgress" }] }, + }), + ], activeTurnId, latestTurnSettled: true, }), diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 56d4b9465c12..72467ce61ab4 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -156,11 +156,12 @@ export interface ActiveComposerTasks { } export function deriveActiveComposerTasks(input: { - activePlan: ActivePlanState | null; + activities: ReadonlyArray; activeTurnId: TurnId | null; latestTurnSettled: boolean; }): ActiveComposerTasks | null { - const { activePlan, activeTurnId, latestTurnSettled } = input; + const { activities, activeTurnId, latestTurnSettled } = input; + const activePlan = deriveActivePlanState(activities, activeTurnId ?? undefined); if (latestTurnSettled || activeTurnId === null || activePlan?.turnId !== activeTurnId) { return null; } From a3d222d648de2dd6140cae4bf8c602b666e80f51 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 09:30:09 -0400 Subject: [PATCH 12/20] docs(user): explain composer task progress Document the Tasks tab, reconnect restoration, settlement behavior, and session-local dismissal. --- docs/user/composer.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/user/composer.md b/docs/user/composer.md index b7ef57a56015..4d854e03e106 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -7,6 +7,15 @@ multiple messages, then send again in the same thread. On servers that support direct uploads, images upload as soon as you add them. The send button becomes available after every upload finishes. Failed uploads can be retried or removed. +## Task progress + +During a multi-step turn, the **Tasks** tab above the composer shows the current step and the number +of completed steps. Open the tab to see completed, current, and pending steps. + +If T3 Code reloads or reconnects while the turn continues, the **Tasks** tab restores the latest +progress. The tab disappears when the turn finishes. To hide it sooner, dismiss it for the current +app session. + ## Commands and skills Type `/` to open the command menu. Type `$` to find and add a skill. Skill rows show their source, From cf7673abd95cf98610bb7cec971d46c6e76305b5 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 10:22:40 -0400 Subject: [PATCH 13/20] test(server): cover provider-aborted turn lifecycle Reproduce stale persisted task progress when turn.aborted clears only the in-memory plan registry and leaves the active session running. --- .../Layers/ProviderRuntimeIngestion.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 84858b6affe9..3d5cef58a90c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -740,6 +740,48 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("settles the active turn when the provider aborts it", async () => { + const harness = await createHarness(); + const turnId = asTurnId("turn-aborted"); + const startedAt = "2026-01-01T00:00:00.000Z"; + const abortedAt = "2026-01-01T00:00:01.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-aborted"), + provider: ProviderDriverKind.make("codex"), + createdAt: startedAt, + threadId: asThreadId("thread-1"), + turnId, + }); + + await waitForThread( + harness.readModel, + (thread) => thread.session?.status === "running" && thread.session.activeTurnId === turnId, + ); + + harness.emit({ + type: "turn.aborted", + eventId: asEventId("evt-turn-aborted"), + provider: ProviderDriverKind.make("codex"), + createdAt: abortedAt, + threadId: asThreadId("thread-1"), + turnId, + payload: { reason: "Provider aborted the turn." }, + }); + + await harness.drain(); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.session?.status).toBe("interrupted"); + expect(thread?.session?.activeTurnId).toBeNull(); + expect(thread?.latestTurn).toMatchObject({ + turnId, + state: "interrupted", + completedAt: abortedAt, + }); + }); + it("accepts claude turn lifecycle when seeded thread id is a synthetic placeholder", async () => { const harness = await createHarness(); const seededAt = "2026-01-01T00:00:00.000Z"; From 52a394444b1080d88af9bec2667dc898383b1cb3 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 10:24:02 -0400 Subject: [PATCH 14/20] fix(server): settle provider-aborted turns Project an active turn.aborted event as an interrupted session with no active turn, while ignoring aborts for other turns. This prevents persisted task plans from lingering after abort or relaunch. --- .../Layers/ProviderRuntimeIngestion.test.ts | 18 ++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 13 ++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 3d5cef58a90c..3ee7799e0256 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -760,6 +760,24 @@ describe("ProviderRuntimeIngestion", () => { (thread) => thread.session?.status === "running" && thread.session.activeTurnId === turnId, ); + harness.emit({ + type: "turn.aborted", + eventId: asEventId("evt-other-turn-aborted"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.500Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-other"), + payload: { reason: "A different turn aborted." }, + }); + + await harness.drain(); + const activeReadModel = await harness.readModel(); + const activeThread = activeReadModel.threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(activeThread?.session?.status).toBe("running"); + expect(activeThread?.session?.activeTurnId).toBe(turnId); + harness.emit({ type: "turn.aborted", eventId: asEventId("evt-turn-aborted"), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 7ec3a7e64243..dc753c95f6d1 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1545,6 +1545,12 @@ const make = Effect.gen(function* () { return true; case "turn.started": return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart; + case "turn.aborted": + return ( + activeTurnId !== null && + eventTurnId !== undefined && + sameId(activeTurnId, eventTurnId) + ); case "turn.completed": if (conflictsWithActiveTurn || missingTurnForActiveTurn) { return false; @@ -1576,6 +1582,7 @@ const make = Effect.gen(function* () { event.type === "session.exited" || event.type === "thread.started" || event.type === "turn.started" || + event.type === "turn.aborted" || event.type === "turn.completed" ) { const status = (() => { @@ -1588,6 +1595,8 @@ const make = Effect.gen(function* () { return "running"; case "session.exited": return "stopped"; + case "turn.aborted": + return "interrupted"; case "turn.completed": return normalizeRuntimeTurnState(event.payload.state) === "failed" ? "error" @@ -1602,7 +1611,9 @@ const make = Effect.gen(function* () { const nextActiveTurnId = event.type === "turn.started" ? (eventTurnId ?? null) - : event.type === "turn.completed" || event.type === "session.exited" + : event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "session.exited" ? null : event.type === "session.state.changed" && !sessionStatusAllowsActiveTurn( From fb3bd8edf5c133d749ab55809421992861ab6fe4 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 10:36:23 -0400 Subject: [PATCH 15/20] test: cover active-turn divergence and untargeted abort Reproduce wrong-plan hydration when session.activeTurnId differs from latestTurnId, and prove an untargeted abort must not clear active plan progress. --- .../Layers/ProjectionSnapshotQuery.test.ts | 76 +++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.test.ts | 57 +++++++++++++- 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 57e5d33ecc3c..5b4b21b1688f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -2467,6 +2467,82 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); + it.effect("pins the session's active-turn plan when latest turn projection diverges", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql` + INSERT INTO projection_turns ( + thread_id, turn_id, pending_message_id, state, requested_at, started_at, completed_at, + checkpoint_files_json + ) + VALUES ( + 'thread-w', 'turn-active', NULL, 'running', + '2026-03-01T00:05:00.000Z', '2026-03-01T00:05:00.000Z', NULL, '[]' + ) + `; + yield* sql` + INSERT INTO projection_thread_sessions ( + thread_id, status, provider_name, provider_instance_id, runtime_mode, + active_turn_id, last_error, updated_at + ) + VALUES ( + 'thread-w', 'running', 'codex', 'codex', 'full-access', + 'turn-active', NULL, '2026-03-01T00:05:00.000Z' + ) + `; + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) + VALUES + ( + 'old-plan', 'thread-w', 'turn-5', 'info', 'turn.plan.updated', 'Old plan', + '{"plan":[{"step":"Old turn","status":"inProgress"}]}', 1, + '2026-03-01T00:04:00.000Z' + ), + ( + 'active-plan', 'thread-w', 'turn-active', 'info', 'turn.plan.updated', 'Active plan', + '{"plan":[{"step":"Active turn","status":"inProgress"}]}', 2, + '2026-03-01T00:05:00.000Z' + ) + `; + yield* sql` + WITH RECURSIVE activity_rows(sequence) AS ( + SELECT 3 + UNION ALL + SELECT sequence + 1 FROM activity_rows WHERE sequence < 502 + ) + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) + SELECT + printf('activity-%04d', sequence), + 'thread-w', + 'turn-active', + 'tool', + 'tool.completed', + 'ran tool', + printf('{"sequence":%d}', sequence), + sequence, + '2026-03-01T00:05:00.000Z' + FROM activity_rows + `; + + const detail = yield* snapshotQuery.getThreadDetailById(threadW); + assert.equal(detail._tag, "Some"); + if (detail._tag === "Some") { + const ids = detail.value.activities.map((activity) => activity.id); + assert.equal(ids.length, 501); + assert.equal(ids.includes(asEventId("active-plan")), true); + assert.equal(ids.includes(asEventId("old-plan")), false); + } + }), + ); + it.effect("a thread with no turns returns its content unwindowed on the first page", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 3ee7799e0256..7c80d7b0ee69 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -321,6 +321,7 @@ describe("ProviderRuntimeIngestion", () => { engine, dispatch, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), + readShell: () => Effect.runPromise(snapshotQuery.getShellSnapshot()), emit: provider.emit, setProviderSession: provider.setSession, drain, @@ -755,10 +756,48 @@ describe("ProviderRuntimeIngestion", () => { turnId, }); - await waitForThread( - harness.readModel, - (thread) => thread.session?.status === "running" && thread.session.activeTurnId === turnId, + await harness.drain(); + const runningReadModel = await harness.readModel(); + const runningThread = runningReadModel.threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), ); + expect(runningThread?.session?.status).toBe("running"); + expect(runningThread?.session?.activeTurnId).toBe(turnId); + + harness.emit({ + type: "turn.plan.updated", + eventId: asEventId("evt-turn-plan-aborted"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.250Z", + threadId: asThreadId("thread-1"), + turnId, + payload: { plan: [{ step: "Keep working", status: "inProgress" }] }, + }); + await harness.drain(); + const shellWithPlan = await harness.readShell(); + expect(shellWithPlan.threads.find((entry) => entry.id === "thread-1")?.planProgress).toEqual({ + step: "Keep working", + completedSteps: 0, + totalSteps: 1, + }); + + harness.emit({ + type: "turn.aborted", + eventId: asEventId("evt-untargeted-turn-aborted"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.375Z", + threadId: asThreadId("thread-1"), + payload: { reason: "An untargeted abort arrived." }, + }); + await harness.drain(); + const shellAfterUntargetedAbort = await harness.readShell(); + expect( + shellAfterUntargetedAbort.threads.find((entry) => entry.id === "thread-1")?.planProgress, + ).toEqual({ + step: "Keep working", + completedSteps: 0, + totalSteps: 1, + }); harness.emit({ type: "turn.aborted", @@ -777,6 +816,14 @@ describe("ProviderRuntimeIngestion", () => { ); expect(activeThread?.session?.status).toBe("running"); expect(activeThread?.session?.activeTurnId).toBe(turnId); + const shellAfterOtherAbort = await harness.readShell(); + expect( + shellAfterOtherAbort.threads.find((entry) => entry.id === "thread-1")?.planProgress, + ).toEqual({ + step: "Keep working", + completedSteps: 0, + totalSteps: 1, + }); harness.emit({ type: "turn.aborted", @@ -798,6 +845,10 @@ describe("ProviderRuntimeIngestion", () => { state: "interrupted", completedAt: abortedAt, }); + const shellAfterActiveAbort = await harness.readShell(); + expect( + shellAfterActiveAbort.threads.find((entry) => entry.id === "thread-1")?.planProgress, + ).toBeNull(); }); it("accepts claude turn lifecycle when seeded thread id is a synthetic placeholder", async () => { From 7d4698cce5257e7597759e1fff23191dd052dc1e Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 10:38:28 -0400 Subject: [PATCH 16/20] fix(server): clear progress only for accepted aborts Keep plan progress when an untargeted or foreign turn.aborted event is rejected, while matched aborts settle and clear the active turn. Cover late duplicates without polling. --- .../Layers/ProviderRuntimeIngestion.test.ts | 17 +++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 6 ++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 7c80d7b0ee69..5055feb86ad8 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -849,6 +849,23 @@ describe("ProviderRuntimeIngestion", () => { expect( shellAfterActiveAbort.threads.find((entry) => entry.id === "thread-1")?.planProgress, ).toBeNull(); + + harness.emit({ + type: "turn.aborted", + eventId: asEventId("evt-late-turn-aborted"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:02.000Z", + threadId: asThreadId("thread-1"), + turnId, + payload: { reason: "Late duplicate abort." }, + }); + await harness.drain(); + const afterLateAbort = await harness.readModel(); + const threadAfterLateAbort = afterLateAbort.threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(threadAfterLateAbort?.session?.status).toBe("interrupted"); + expect(threadAfterLateAbort?.session?.activeTurnId).toBeNull(); }); it("accepts claude turn lifecycle when seeded thread id is a synthetic placeholder", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index dc753c95f6d1..10d5428c3c03 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1984,11 +1984,13 @@ const make = Effect.gen(function* () { // active turn's progress; session.exited always clears. if (event.type === "session.exited") { threadPlanProgress.clearThreadPlanProgress(thread.id); + } else if (event.type === "turn.completed" || event.type === "turn.aborted") { + if (shouldApplyThreadLifecycle) { + threadPlanProgress.clearThreadPlanProgress(thread.id); + } } else if (!conflictsWithActiveTurn) { if (event.type === "turn.plan.updated") { threadPlanProgress.recordPlanProgress(thread.id, event.payload.plan); - } else if (event.type === "turn.completed" || event.type === "turn.aborted") { - threadPlanProgress.clearThreadPlanProgress(thread.id); } } From ec648cfff91c0844bfe34be60bf46576a9ef8c89 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 10:38:42 -0400 Subject: [PATCH 17/20] fix: bind restored tasks to the active session turn Prefer session.activeTurnId while a session runs in both hydration and web derivation, falling back to latestTurnId only outside a running session. --- .../Layers/ProjectionSnapshotQuery.ts | 25 +++++++++++++------ ..._ProjectionThreadPlanActivityIndex.test.ts | 25 +++++++++++++------ apps/web/src/components/ChatView.tsx | 9 +++++-- apps/web/src/session-logic.test.ts | 21 ++++++++++++++++ apps/web/src/session-logic.ts | 7 ++++++ 5 files changed, 69 insertions(+), 18 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index f1d37338488d..7dc24433dbfa 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1316,20 +1316,29 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ) AND json_extract(activity.payload_json, '$.requestId') IS NOT NULL ), - latest_unsettled_turn AS ( - SELECT thread.latest_turn_id AS turn_id + restorable_turn_candidate AS ( + SELECT + CASE + WHEN session.status = 'running' THEN session.active_turn_id + ELSE thread.latest_turn_id + END AS turn_id, + session.status AS session_status FROM projection_threads AS thread - LEFT JOIN projection_turns AS turn - ON turn.thread_id = thread.thread_id - AND turn.turn_id = thread.latest_turn_id LEFT JOIN projection_thread_sessions AS session ON session.thread_id = thread.thread_id WHERE thread.thread_id = ${threadId} - AND thread.latest_turn_id IS NOT NULL + ), + latest_unsettled_turn AS ( + SELECT candidate.turn_id + FROM restorable_turn_candidate AS candidate + LEFT JOIN projection_turns AS turn + ON turn.thread_id = ${threadId} + AND turn.turn_id = candidate.turn_id + WHERE candidate.turn_id IS NOT NULL AND ( - turn.started_at IS NULL + candidate.session_status = 'running' + OR turn.started_at IS NULL OR turn.completed_at IS NULL - OR session.status = 'running' ) ), active_plan_activities AS ( diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadPlanActivityIndex.test.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadPlanActivityIndex.test.ts index a39a98130184..a87abadd9f92 100644 --- a/apps/server/src/persistence/Migrations/042_ProjectionThreadPlanActivityIndex.test.ts +++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadPlanActivityIndex.test.ts @@ -20,20 +20,29 @@ layer("042_ProjectionThreadPlanActivityIndex", (it) => { readonly detail: string; }>` EXPLAIN QUERY PLAN - WITH latest_unsettled_turn AS ( - SELECT thread.latest_turn_id AS turn_id + WITH restorable_turn_candidate AS ( + SELECT + CASE + WHEN session.status = 'running' THEN session.active_turn_id + ELSE thread.latest_turn_id + END AS turn_id, + session.status AS session_status FROM projection_threads AS thread - LEFT JOIN projection_turns AS turn - ON turn.thread_id = thread.thread_id - AND turn.turn_id = thread.latest_turn_id LEFT JOIN projection_thread_sessions AS session ON session.thread_id = thread.thread_id WHERE thread.thread_id = 'thread-1' - AND thread.latest_turn_id IS NOT NULL + ), + latest_unsettled_turn AS ( + SELECT candidate.turn_id + FROM restorable_turn_candidate AS candidate + LEFT JOIN projection_turns AS turn + ON turn.thread_id = 'thread-1' + AND turn.turn_id = candidate.turn_id + WHERE candidate.turn_id IS NOT NULL AND ( - turn.started_at IS NULL + candidate.session_status = 'running' + OR turn.started_at IS NULL OR turn.completed_at IS NULL - OR session.status = 'running' ) ), active_plan_activities AS ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3680071eb238..66909450681c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -103,6 +103,7 @@ import { deriveWorkLogEntries, hasActionableProposedPlan, isLatestTurnSettled, + resolveActivePlanTurnId, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring"; @@ -2360,14 +2361,18 @@ function ChatViewContent(props: ChatViewProps) { activeLatestTurn?.turnId ?? null, ); }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); + const activePlanTurnId = resolveActivePlanTurnId( + activeLatestTurn?.turnId ?? null, + activeThread?.session ?? null, + ); const activeComposerTasks = useMemo( () => deriveActiveComposerTasks({ activities: threadActivities, - activeTurnId: activeLatestTurn?.turnId ?? null, + activeTurnId: activePlanTurnId, latestTurnSettled, }), - [activeLatestTurn?.turnId, latestTurnSettled, threadActivities], + [activePlanTurnId, latestTurnSettled, threadActivities], ); const workingStepLabel = activeComposerTasks?.progress.step ?? null; const showPlanFollowUpPrompt = diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 91169ffb7aad..bd6b3bd2de98 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -20,6 +20,7 @@ import { findLatestProposedPlan, hasActionableProposedPlan, isLatestTurnSettled, + resolveActivePlanTurnId, workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workEntryIndicatesToolSuccess, @@ -597,6 +598,26 @@ describe("deriveActiveComposerTasks", () => { }); }); +describe("resolveActivePlanTurnId", () => { + it("prefers the running session turn when latest turn projection diverges", () => { + expect( + resolveActivePlanTurnId(TurnId.make("turn-latest"), { + status: "running", + activeTurnId: TurnId.make("turn-active"), + }), + ).toBe(TurnId.make("turn-active")); + }); + + it("uses the latest projected turn when the session is not running", () => { + expect( + resolveActivePlanTurnId(TurnId.make("turn-latest"), { + status: "ready", + activeTurnId: null, + }), + ).toBe(TurnId.make("turn-latest")); + }); +}); + describe("deriveTurnPlans", () => { it("keeps one entry per turn, anchored at the first snapshot with the latest steps", () => { const activities: OrchestrationThreadActivity[] = [ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 72467ce61ab4..1837477bdf7b 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -155,6 +155,13 @@ export interface ActiveComposerTasks { steps: ActivePlanState["steps"]; } +export function resolveActivePlanTurnId( + latestTurnId: TurnId | null, + session: Pick | null, +): TurnId | null { + return session?.status === "running" ? session.activeTurnId : latestTurnId; +} + export function deriveActiveComposerTasks(input: { activities: ReadonlyArray; activeTurnId: TurnId | null; From 80ce111580272caf8cbadeba6698317f09eb6c9f Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 10:40:43 -0400 Subject: [PATCH 18/20] test(server): cover abort finalization Reproduce lost buffered assistant text and proposed-plan deltas when turn.aborted is the only accepted terminal event. --- .../Layers/ProviderRuntimeIngestion.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 5055feb86ad8..522639d9ffbd 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -773,6 +773,28 @@ describe("ProviderRuntimeIngestion", () => { turnId, payload: { plan: [{ step: "Keep working", status: "inProgress" }] }, }); + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-turn-aborted-assistant-delta"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.250Z", + threadId: asThreadId("thread-1"), + turnId, + itemId: asItemId("item-turn-aborted"), + payload: { + streamKind: "assistant_text", + delta: "Partial answer before abort.", + }, + }); + harness.emit({ + type: "turn.proposed.delta", + eventId: asEventId("evt-turn-aborted-plan-delta"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.250Z", + threadId: asThreadId("thread-1"), + turnId, + payload: { delta: "## Partial plan before abort" }, + }); await harness.drain(); const shellWithPlan = await harness.readShell(); expect(shellWithPlan.threads.find((entry) => entry.id === "thread-1")?.planProgress).toEqual({ @@ -845,6 +867,19 @@ describe("ProviderRuntimeIngestion", () => { state: "interrupted", completedAt: abortedAt, }); + expect( + thread?.messages.find((message) => message.id === "assistant:item-turn-aborted"), + ).toMatchObject({ + text: "Partial answer before abort.", + streaming: false, + }); + expect( + thread?.proposedPlans.find( + (proposedPlan) => proposedPlan.id === "plan:thread-1:turn:turn-aborted", + ), + ).toMatchObject({ + planMarkdown: "## Partial plan before abort", + }); const shellAfterActiveAbort = await harness.readShell(); expect( shellAfterActiveAbort.threads.find((entry) => entry.id === "thread-1")?.planProgress, From 82f78bc5f65ee97dbf23e9f126e4efc1daf4f7ba Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 10:41:42 -0400 Subject: [PATCH 19/20] fix(server): finalize buffered content on abort Treat an accepted turn.aborted as a terminal event for buffered assistant messages and proposed plans before clearing turn state. --- .../src/orchestration/Layers/ProviderRuntimeIngestion.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 10d5428c3c03..1194acc64ed8 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1854,7 +1854,10 @@ const make = Effect.gen(function* () { }); } - if (event.type === "turn.completed") { + if ( + event.type === "turn.completed" || + (event.type === "turn.aborted" && shouldApplyThreadLifecycle) + ) { const detailedThread = yield* getLoadedThreadDetail(); const messages = detailedThread?.messages ?? []; const proposedPlans = detailedThread?.proposedPlans ?? []; From 1e8276a76ff194d8b03bc65aa78750b4505ff036 Mon Sep 17 00:00:00 2001 From: sethwebster Date: Mon, 24 Aug 2026 10:49:34 -0400 Subject: [PATCH 20/20] fix(web): key task dismissal to the active turn Pass the resolved session-active Tasks turn into ChatComposer so session-local dismissal cannot target a stale latestTurnId. --- apps/web/src/components/ChatView.tsx | 1 + apps/web/src/components/chat/ChatComposer.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 66909450681c..d3ba37611acd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6834,6 +6834,7 @@ function ChatViewContent(props: ChatViewProps) { respondingRequestIds={respondingRequestIds} showPlanFollowUpPrompt={showPlanFollowUpPrompt} activeProposedPlan={activeProposedPlan} + activeTasksTurnId={activeComposerTasks ? activePlanTurnId : null} activeTasksProgress={activeComposerTasksProgress} activeTaskSteps={activeComposerTaskSteps} runtimeMode={runtimeMode} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 1ec58e0de702..b196fdd8ed68 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -591,6 +591,7 @@ export interface ChatComposerProps { // Plan showPlanFollowUpPrompt: boolean; activeProposedPlan: Thread["proposedPlans"][number] | null; + activeTasksTurnId: TurnId | null; activeTasksProgress: ComposerTasksProgress | null; activeTaskSteps: readonly ComposerTaskStep[] | null; @@ -689,6 +690,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) respondingRequestIds, showPlanFollowUpPrompt, activeProposedPlan, + activeTasksTurnId, activeTasksProgress, activeTaskSteps, runtimeMode, @@ -2388,7 +2390,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const toggleTasksDrawer = useCallback(() => { setIsTasksDrawerOpen((open) => !open); }, []); - const activeTasksTurnId = activeThread?.latestTurn?.turnId ?? null; const tasksDismissedForActiveTurn = activeTasksTurnId !== null && dismissedTasksTurnId === activeTasksTurnId; const visibleTasksProgress = tasksDismissedForActiveTurn ? null : activeTasksProgress;