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}`
|
? `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) {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user