fix(coding): reconcile snapshot after abort

This commit is contained in:
2026-09-01 15:17:45 +08:00
parent 143aaec3d6
commit d2ef37bc4d
6 changed files with 165 additions and 13 deletions

View File

@@ -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.

View File

@@ -170,6 +170,10 @@ export function CodingChatPanel({
? `new:${activeProject.id}:${selectedAgent.id}` ? `new:${activeProject.id}:${selectedAgent.id}`
: null; : null;
const draftKey = targetConversationId ?? provisionalDraftKey; 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 promptMode = draftKey ? modesByDraftKey[draftKey] ?? 'prompt' : 'prompt';
const provisionalDraft = provisionalDraftKey ? provisionalDrafts[provisionalDraftKey] ?? '' : ''; const provisionalDraft = provisionalDraftKey ? provisionalDrafts[provisionalDraftKey] ?? '' : '';
const submissionError = draftKey ? submissionErrors[draftKey] ?? null : null; const submissionError = draftKey ? submissionErrors[draftKey] ?? null : null;
@@ -598,6 +602,9 @@ export function CodingChatPanel({
onRename={async (title) => { onRename={async (title) => {
if (targetConversationId) await patchConversation(targetConversationId, { title }); if (targetConversationId) await patchConversation(targetConversationId, { title });
}} }}
onAbort={async () => {
if (targetConversationId) await abortConversation(targetConversationId);
}}
onRecover={async () => { onRecover={async () => {
if (targetConversationId) await recoverConversation(targetConversationId); if (targetConversationId) await recoverConversation(targetConversationId);
}} }}
@@ -698,13 +705,15 @@ export function CodingChatPanel({
onSubmit={handleSubmit} onSubmit={handleSubmit}
onAbort={() => { onAbort={() => {
if (!targetConversationId) return; if (!targetConversationId) return;
void abortCodingConversation(targetConversationId).catch((error) => { const conversationId = targetConversationId;
if (!draftKey) return; void abortConversation(conversationId)
setSubmissionErrors((current) => ({ .catch((error) => {
...current, if (!draftKey) return;
[draftKey]: localSubmissionError(error), setSubmissionErrors((current) => ({
})); ...current,
}); [draftKey]: localSubmissionError(error),
}));
});
}} }}
onRecover={() => { onRecover={() => {
if (targetConversationId) { if (targetConversationId) {

View File

@@ -16,7 +16,6 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { abortCodingConversation } from '@/lib/coding-conversations';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useSettingsStore } from '@/stores/settings'; import { useSettingsStore } from '@/stores/settings';
import type { ConversationSnapshot } from '@/types/coding-conversation'; import type { ConversationSnapshot } from '@/types/coding-conversation';
@@ -54,11 +53,13 @@ export function CodingConversationHeader({
conversation, conversation,
snapshot, snapshot,
onRename, onRename,
onAbort,
onRecover, onRecover,
}: { }: {
conversation: CodingConversationMetadata | null; conversation: CodingConversationMetadata | null;
snapshot: ConversationSnapshot | null; snapshot: ConversationSnapshot | null;
onRename(title: string): Promise<void>; onRename(title: string): Promise<void>;
onAbort(): Promise<void>;
onRecover(): Promise<void>; onRecover(): Promise<void>;
}) { }) {
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
@@ -128,7 +129,7 @@ export function CodingConversationHeader({
disabled={Boolean(busyAction) || !conversation} disabled={Boolean(busyAction) || !conversation}
aria-label="中止" aria-label="中止"
title="中止" title="中止"
onClick={() => perform('abort', async () => abortCodingConversation(conversation!.id))} onClick={() => perform('abort', onAbort)}
> >
{busyAction === 'abort' {busyAction === 'abort'
? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" /> ? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" />

View File

@@ -63,6 +63,7 @@ async function installCodingFirstChatHost(
captured: CapturedRequest[]; captured: CapturedRequest[];
conversationCreated: boolean; conversationCreated: boolean;
interactionAnswered: boolean; interactionAnswered: boolean;
abortRequested: boolean;
releaseSnapshot: (() => void) | null; releaseSnapshot: (() => void) | null;
snapshotPending: boolean; snapshotPending: boolean;
}; };
@@ -73,6 +74,7 @@ async function installCodingFirstChatHost(
captured: [], captured: [],
conversationCreated: false, conversationCreated: false,
interactionAnswered: false, interactionAnswered: false,
abortRequested: false,
releaseSnapshot: null, releaseSnapshot: null,
snapshotPending: false, snapshotPending: false,
}; };
@@ -470,9 +472,44 @@ async function installCodingFirstChatHost(
state.snapshotPending = false; state.snapshotPending = false;
} }
return respond({ return respond({
snapshot: state.interactionAnswered snapshot: state.abortRequested
? { ...snapshot, pendingInteractions: [] } ? {
: snapshot, ...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`) { if (path === `/api/coding/conversations/${secondConversation.id}/snapshot`) {
@@ -489,7 +526,11 @@ async function installCodingFirstChatHost(
}, },
}, 202); }, 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({}); return respond({});
} }
if (/^\/api\/coding\/conversations\/[^/]+\/model$/.test(path) && method === 'POST') { 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).not.toHaveClass(/bg-surface-subtle\/75/);
await expect(runtimeSettings).toBeDisabled(); await expect(runtimeSettings).toBeDisabled();
await page.getByRole('button', { name: '中止', exact: true }).click(); 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 的对话' }); const builderConversations = page.getByRole('group', { name: 'Builder 的对话' });
await expect(builderConversations).toBeVisible(); 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 expect(page.getByText('本地编程运行时暂时不可用')).toHaveCount(0);
await builderConversations.getByRole('button', { name: /^新对话/ }).click(); await builderConversations.getByRole('button', { name: /^新对话/ }).click();
await expect(page.getByText('Durable user fork source')).toBeVisible(); 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.getByText('Durable assistant response')).toBeVisible();
await expect(page.getByRole('button', { name: '从这里创建新对话分支' })).toHaveCount(1); await expect(page.getByRole('button', { name: '从这里创建新对话分支' })).toHaveCount(1);
await builderConversations.getByRole('button', { name: 'Second Conversation' }).click(); await builderConversations.getByRole('button', { name: 'Second Conversation' }).click();

View File

@@ -695,6 +695,45 @@ describe('CodingChatPanel first Conversation', () => {
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled(); 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 () => { it('caps one message at 16 images and uploads at most four concurrently', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config }); projectApi.config.mockResolvedValue({ project, config });

View File

@@ -219,6 +219,7 @@ describe('PI-130 feature-complete Coding UI', () => {
conversation={null} conversation={null}
snapshot={null} snapshot={null}
onRename={vi.fn(async () => undefined)} onRename={vi.fn(async () => undefined)}
onAbort={vi.fn(async () => undefined)}
onRecover={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' }); interactionApi.thinking.mockResolvedValue({ model: null, modelResolution: 'required' });
const callbacks = { const callbacks = {
rename: vi.fn(async () => undefined), rename: vi.fn(async () => undefined),
abort: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined), refresh: vi.fn(async () => undefined),
recover: vi.fn(async () => undefined), recover: vi.fn(async () => undefined),
}; };
@@ -410,6 +412,7 @@ describe('PI-130 feature-complete Coding UI', () => {
conversation={conversation} conversation={conversation}
snapshot={snapshot} snapshot={snapshot}
onRename={callbacks.rename} onRename={callbacks.rename}
onAbort={callbacks.abort}
onRecover={callbacks.recover} onRecover={callbacks.recover}
/> />
<CodingComposer <CodingComposer
@@ -635,6 +638,7 @@ describe('PI-130 feature-complete Coding UI', () => {
conversation={conversation} conversation={conversation}
snapshot={snapshot} snapshot={snapshot}
onRename={vi.fn(async () => undefined)} onRename={vi.fn(async () => undefined)}
onAbort={async () => interactionApi.abort('conversation-uncertain')}
onRecover={recover} onRecover={recover}
/> />
<CodingComposerRuntimeControls <CodingComposerRuntimeControls