fix(coding): reconcile snapshot after abort
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# Task: Fix running conversation abort stall
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260901-conversation-abort-stall-6f4c2a91
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260901-conversation-abort-stall-6f4c2a91-conversation-abort-stall
|
||||
- Worktree: D:\Datas\OthersProjects\makelore-conversation-abort-stall-6f4c2a91
|
||||
- Base commit: 143aaec3d6770dd263bf1e418e89bf722989caef
|
||||
- Owner: codex-root
|
||||
- Status: Completed
|
||||
|
||||
## Scope
|
||||
|
||||
- Diagnose the installed 1.2.1 state where a Conversation continued to render as running and neither visible abort entry point unlocked the UI.
|
||||
- Correlate the screenshot with privacy-safe Main lifecycle and Pi Session metadata, then reproduce the stale-Renderer state at the public Chat seam.
|
||||
- Make both the Header and Composer abort actions reconcile the authoritative Conversation Snapshot after the abort request completes.
|
||||
- Add focused Renderer regression coverage and extend the existing Electron E2E fixture for a missed terminal SSE patch.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Preserve ADR-006: Pi `0.84.2` remains the sole production runtime and Electron Main remains the only Pi/Host API authority.
|
||||
- Do not replay accepted or uncertain work, change the abort RPC/Host API contract, add polling, or introduce a fallback/compatibility path.
|
||||
- Treat the visible running state as evidence to investigate, not proof that the bash subprocess or Pi turn is still active.
|
||||
- Keep the change at the existing Renderer Host API boundary and use the existing target-only Snapshot recovery semantics.
|
||||
- Work only in the isolated feature worktree; do not modify the occupied `main` worktree or the completed 1.2.1 packaging task.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Confirmed the screenshot was from installed Makelore 1.2.1. The affected Pi JSONL Session recorded three bash tool results and a final assistant `stop` by 14:55:35, while the 14:59 screenshot still rendered the earlier bash call as executing. Main later stopped the already-idle logical thread through background sleep, so the supported incident was stale Renderer state rather than an indefinitely running curl process.
|
||||
- Reproduced the defect with a focused Chat test: the UI held a running Snapshot, the abort request succeeded against an already-terminal authority, but `getCodingConversationSnapshot` remained at one call and the UI stayed on `中止生成`.
|
||||
- Added one shared `abortConversation` path in `CodingChatPanel`: after the existing POST abort completes, it silently reloads that Conversation's authoritative Snapshot. Both the Header `中止` button and Composer `中止生成` button now use this path.
|
||||
- Kept error ownership in the existing controls: Header action failures remain local to Header, while Composer failures remain scoped to the originating draft/Conversation.
|
||||
- Updated the Electron E2E host fixture so a deliberately missed terminal SSE patch becomes an authoritative aborted Snapshot only after the user clicks abort; the test now proves both abort controls disappear and runtime settings unlock after reconciliation.
|
||||
|
||||
## Verification
|
||||
|
||||
- Red regression before the fix: `tests/unit/coding-chat-panel.test.tsx` failed because the Snapshot API was called once instead of twice after abort.
|
||||
- `pnpm exec vitest run tests/unit/coding-chat-panel.test.tsx tests/unit/coding-feature-ui.test.tsx tests/unit/coding-conversations-facade.test.ts tests/unit/pi-conversation-runtime.test.ts tests/unit/pi-worker-pool-process-integration.test.ts` passed: 5 files, 35 tests.
|
||||
- `pnpm run typecheck` passed.
|
||||
- Scoped ESLint over the two product files and three changed test files passed with no findings.
|
||||
- `pnpm run build:vite` passed for Renderer, Electron Main, Preload, and release utility bundles; only existing bundle-size/dynamic-import warnings were reported.
|
||||
- `pnpm exec playwright test tests/e2e/pi-coding-first-chat.spec.ts --grep "PI feature UI"` passed: 1/1 Electron E2E.
|
||||
- `pnpm test` passed: 215 files / 1780 tests, 2 skipped, plus the isolated pressure test 1/1.
|
||||
- `pnpm run lint:check` completed with 0 errors and 5 existing warnings in `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`; no warning is in a changed file.
|
||||
- `git diff --check` passed.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- A new Windows installer must be built and installed before claiming the user's installed app contains this fix. Do not overwrite or relabel the already-built 1.2.1 artifact with changed source under the same version.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None. This fix enforces the existing abort/terminal convergence and Snapshot recovery contracts without changing architecture or product direction.
|
||||
@@ -170,6 +170,10 @@ export function CodingChatPanel({
|
||||
? `new:${activeProject.id}:${selectedAgent.id}`
|
||||
: null;
|
||||
const draftKey = targetConversationId ?? provisionalDraftKey;
|
||||
const abortConversation = useCallback(async (conversationId: string) => {
|
||||
await abortCodingConversation(conversationId);
|
||||
await loadConversationSnapshot(conversationId, 'silent');
|
||||
}, [loadConversationSnapshot]);
|
||||
const promptMode = draftKey ? modesByDraftKey[draftKey] ?? 'prompt' : 'prompt';
|
||||
const provisionalDraft = provisionalDraftKey ? provisionalDrafts[provisionalDraftKey] ?? '' : '';
|
||||
const submissionError = draftKey ? submissionErrors[draftKey] ?? null : null;
|
||||
@@ -598,6 +602,9 @@ export function CodingChatPanel({
|
||||
onRename={async (title) => {
|
||||
if (targetConversationId) await patchConversation(targetConversationId, { title });
|
||||
}}
|
||||
onAbort={async () => {
|
||||
if (targetConversationId) await abortConversation(targetConversationId);
|
||||
}}
|
||||
onRecover={async () => {
|
||||
if (targetConversationId) await recoverConversation(targetConversationId);
|
||||
}}
|
||||
@@ -698,13 +705,15 @@ export function CodingChatPanel({
|
||||
onSubmit={handleSubmit}
|
||||
onAbort={() => {
|
||||
if (!targetConversationId) return;
|
||||
void abortCodingConversation(targetConversationId).catch((error) => {
|
||||
if (!draftKey) return;
|
||||
setSubmissionErrors((current) => ({
|
||||
...current,
|
||||
[draftKey]: localSubmissionError(error),
|
||||
}));
|
||||
});
|
||||
const conversationId = targetConversationId;
|
||||
void abortConversation(conversationId)
|
||||
.catch((error) => {
|
||||
if (!draftKey) return;
|
||||
setSubmissionErrors((current) => ({
|
||||
...current,
|
||||
[draftKey]: localSubmissionError(error),
|
||||
}));
|
||||
});
|
||||
}}
|
||||
onRecover={() => {
|
||||
if (targetConversationId) {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { abortCodingConversation } from '@/lib/coding-conversations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import type { ConversationSnapshot } from '@/types/coding-conversation';
|
||||
@@ -54,11 +53,13 @@ export function CodingConversationHeader({
|
||||
conversation,
|
||||
snapshot,
|
||||
onRename,
|
||||
onAbort,
|
||||
onRecover,
|
||||
}: {
|
||||
conversation: CodingConversationMetadata | null;
|
||||
snapshot: ConversationSnapshot | null;
|
||||
onRename(title: string): Promise<void>;
|
||||
onAbort(): Promise<void>;
|
||||
onRecover(): Promise<void>;
|
||||
}) {
|
||||
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||
@@ -128,7 +129,7 @@ export function CodingConversationHeader({
|
||||
disabled={Boolean(busyAction) || !conversation}
|
||||
aria-label="中止"
|
||||
title="中止"
|
||||
onClick={() => perform('abort', async () => abortCodingConversation(conversation!.id))}
|
||||
onClick={() => perform('abort', onAbort)}
|
||||
>
|
||||
{busyAction === 'abort'
|
||||
? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
|
||||
@@ -63,6 +63,7 @@ async function installCodingFirstChatHost(
|
||||
captured: CapturedRequest[];
|
||||
conversationCreated: boolean;
|
||||
interactionAnswered: boolean;
|
||||
abortRequested: boolean;
|
||||
releaseSnapshot: (() => void) | null;
|
||||
snapshotPending: boolean;
|
||||
};
|
||||
@@ -73,6 +74,7 @@ async function installCodingFirstChatHost(
|
||||
captured: [],
|
||||
conversationCreated: false,
|
||||
interactionAnswered: false,
|
||||
abortRequested: false,
|
||||
releaseSnapshot: null,
|
||||
snapshotPending: false,
|
||||
};
|
||||
@@ -470,9 +472,44 @@ async function installCodingFirstChatHost(
|
||||
state.snapshotPending = false;
|
||||
}
|
||||
return respond({
|
||||
snapshot: state.interactionAnswered
|
||||
? { ...snapshot, pendingInteractions: [] }
|
||||
: snapshot,
|
||||
snapshot: state.abortRequested
|
||||
? {
|
||||
...snapshot,
|
||||
nodes: snapshot.nodes.map((node) => {
|
||||
if (node.kind === 'message' && node.role === 'assistant') {
|
||||
return {
|
||||
...node,
|
||||
status: 'aborted',
|
||||
blocks: node.blocks.map((block) => ({ ...block, status: 'complete' })),
|
||||
};
|
||||
}
|
||||
if (node.kind === 'subagent') {
|
||||
return {
|
||||
...node,
|
||||
details: {
|
||||
...node.details,
|
||||
tasks: node.details.tasks.map((task) => (
|
||||
task.status === 'running' ? { ...task, status: 'aborted' } : task
|
||||
)),
|
||||
},
|
||||
};
|
||||
}
|
||||
return node;
|
||||
}),
|
||||
run: {
|
||||
status: 'idle',
|
||||
runId: 'run-e2e-feature',
|
||||
mode: 'prompt',
|
||||
settledAt: 12_000,
|
||||
terminalReason: 'aborted',
|
||||
},
|
||||
queue: { items: [] },
|
||||
pendingInteractions: [],
|
||||
cursor: { workerGeneration: 1, seq: 1 },
|
||||
}
|
||||
: state.interactionAnswered
|
||||
? { ...snapshot, pendingInteractions: [] }
|
||||
: snapshot,
|
||||
});
|
||||
}
|
||||
if (path === `/api/coding/conversations/${secondConversation.id}/snapshot`) {
|
||||
@@ -489,7 +526,11 @@ async function installCodingFirstChatHost(
|
||||
},
|
||||
}, 202);
|
||||
}
|
||||
if (/^\/api\/coding\/conversations\/[^/]+\/(abort|compact|recover)$/.test(path) && method === 'POST') {
|
||||
if (/^\/api\/coding\/conversations\/[^/]+\/abort$/.test(path) && method === 'POST') {
|
||||
state.abortRequested = true;
|
||||
return respond({});
|
||||
}
|
||||
if (/^\/api\/coding\/conversations\/[^/]+\/(compact|recover)$/.test(path) && method === 'POST') {
|
||||
return respond({});
|
||||
}
|
||||
if (/^\/api\/coding\/conversations\/[^/]+\/model$/.test(path) && method === 'POST') {
|
||||
@@ -795,6 +836,9 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
|
||||
await expect(runtimeSettings).not.toHaveClass(/bg-surface-subtle\/75/);
|
||||
await expect(runtimeSettings).toBeDisabled();
|
||||
await page.getByRole('button', { name: '中止', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: '中止', exact: true })).toHaveCount(0);
|
||||
await expect(composer.getByRole('button', { name: '中止生成' })).toHaveCount(0);
|
||||
await expect(runtimeSettings).toBeEnabled();
|
||||
|
||||
const builderConversations = page.getByRole('group', { name: 'Builder 的对话' });
|
||||
await expect(builderConversations).toBeVisible();
|
||||
@@ -806,6 +850,7 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
|
||||
await expect(page.getByText('本地编程运行时暂时不可用')).toHaveCount(0);
|
||||
await builderConversations.getByRole('button', { name: /^新对话/ }).click();
|
||||
await expect(page.getByText('Durable user fork source')).toBeVisible();
|
||||
await page.getByTestId('coding-process-group').locator('summary').first().click();
|
||||
await expect(page.getByText('Durable assistant response')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '从这里创建新对话分支' })).toHaveCount(1);
|
||||
await builderConversations.getByRole('button', { name: 'Second Conversation' }).click();
|
||||
|
||||
@@ -695,6 +695,45 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('refreshes the authoritative Snapshot after abort when the terminal SSE patch was missed', async () => {
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config });
|
||||
projectApi.conversations.mockResolvedValue([conversation]);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
conversationApi.abort.mockResolvedValue(undefined);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const runningSnapshot: ConversationSnapshot = {
|
||||
...createLocalConversationSnapshot(project.id, conversation),
|
||||
worker: { status: 'ready', generation: 1 },
|
||||
run: { status: 'running', runId: 'run-stale', mode: 'prompt' },
|
||||
cursor: { workerGeneration: 1, seq: 7 },
|
||||
};
|
||||
const settledSnapshot: ConversationSnapshot = {
|
||||
...runningSnapshot,
|
||||
run: {
|
||||
status: 'idle',
|
||||
runId: 'run-stale',
|
||||
mode: 'prompt',
|
||||
settledAt: 12_000,
|
||||
terminalReason: 'completed',
|
||||
},
|
||||
cursor: { workerGeneration: 1, seq: 8 },
|
||||
};
|
||||
conversationApi.snapshot
|
||||
.mockResolvedValueOnce(runningSnapshot)
|
||||
.mockResolvedValueOnce(settledSnapshot);
|
||||
render(<CodingChatPanel />);
|
||||
|
||||
const abortButton = await screen.findByRole('button', { name: '中止生成' });
|
||||
fireEvent.click(abortButton);
|
||||
|
||||
await waitFor(() => expect(conversationApi.abort).toHaveBeenCalledWith(conversation.id));
|
||||
await waitFor(() => expect(conversationApi.snapshot).toHaveBeenCalledTimes(2));
|
||||
expect(await screen.findByRole('button', { name: '发送' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('caps one message at 16 images and uploads at most four concurrently', async () => {
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config });
|
||||
|
||||
@@ -219,6 +219,7 @@ describe('PI-130 feature-complete Coding UI', () => {
|
||||
conversation={null}
|
||||
snapshot={null}
|
||||
onRename={vi.fn(async () => undefined)}
|
||||
onAbort={vi.fn(async () => undefined)}
|
||||
onRecover={vi.fn(async () => undefined)}
|
||||
/>
|
||||
</>,
|
||||
@@ -367,6 +368,7 @@ describe('PI-130 feature-complete Coding UI', () => {
|
||||
interactionApi.thinking.mockResolvedValue({ model: null, modelResolution: 'required' });
|
||||
const callbacks = {
|
||||
rename: vi.fn(async () => undefined),
|
||||
abort: vi.fn(async () => undefined),
|
||||
refresh: vi.fn(async () => undefined),
|
||||
recover: vi.fn(async () => undefined),
|
||||
};
|
||||
@@ -410,6 +412,7 @@ describe('PI-130 feature-complete Coding UI', () => {
|
||||
conversation={conversation}
|
||||
snapshot={snapshot}
|
||||
onRename={callbacks.rename}
|
||||
onAbort={callbacks.abort}
|
||||
onRecover={callbacks.recover}
|
||||
/>
|
||||
<CodingComposer
|
||||
@@ -635,6 +638,7 @@ describe('PI-130 feature-complete Coding UI', () => {
|
||||
conversation={conversation}
|
||||
snapshot={snapshot}
|
||||
onRename={vi.fn(async () => undefined)}
|
||||
onAbort={async () => interactionApi.abort('conversation-uncertain')}
|
||||
onRecover={recover}
|
||||
/>
|
||||
<CodingComposerRuntimeControls
|
||||
|
||||
Reference in New Issue
Block a user