merge: integrate conversation abort reconciliation
This commit is contained in:
@@ -4,6 +4,19 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
|
||||
|
||||
## Integrated Through
|
||||
|
||||
- Conversation abort reconciliation source
|
||||
`d2ef37bc4d7d3609cec0a55c4ae8ffb2734696d5` from task
|
||||
`20260901-conversation-abort-stall-6f4c2a91` is prepared for local `main`
|
||||
promotion by integration task `20260901-promote-conversation-abort-7b4e2c91`.
|
||||
Both visible abort actions now call the Main-owned abort route and then silently
|
||||
reload only the target Conversation's authoritative Snapshot. This closes the
|
||||
installed 1.2.1 case where Pi had already recorded bash results and a final
|
||||
assistant `stop`, but Renderer missed the terminal SSE patch and a later valid
|
||||
abort no-op left the UI showing an executing turn. Pi `0.84.2`, Host API,
|
||||
replay, recovery, and background-lifecycle contracts are unchanged. Focused
|
||||
35-test, 1,780-test full-suite plus pressure, typecheck, lint, production-build,
|
||||
and Electron E2E verification passed. The existing 1.2.1 artifact predates this
|
||||
fix; a newly versioned package and installed-client acceptance remain pending.
|
||||
- Agent Server background-sleep race fix source
|
||||
`12d7588b3ebd4d192c2e14ae285d4f6ddebeeb42` from task
|
||||
`20260901-agent-received-stall-8b6d4c21` is merged into local `main` as
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Task: Promote conversation abort reconciliation
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260901-promote-conversation-abort-7b4e2c91
|
||||
- Mode: Integration
|
||||
- Branch: codex/20260901-promote-conversation-abort-7b4e2c91-promote-conversation-abort
|
||||
- Worktree: D:\Datas\OthersProjects\makelore-promote-conversation-abort-7b4e2c91
|
||||
- Base commit: d2ef37bc4d7d3609cec0a55c4ae8ffb2734696d5
|
||||
- Owner: codex-root
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Reconcile verified source commit `d2ef37b` from feature task
|
||||
`20260901-conversation-abort-stall-6f4c2a91` into canonical current state from a
|
||||
correctly based, exclusively owned Integration Gate.
|
||||
- Preserve the verified product tree exactly and prepare a clean candidate for local
|
||||
`main` promotion.
|
||||
- Keep the occupied `main` worktree and its completed 1.2.1 packaging-task record
|
||||
read-only until the human explicitly authorizes ownership transfer.
|
||||
|
||||
## 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.
|
||||
- Record an implementation correction to existing abort/Snapshot convergence, not a
|
||||
new public contract, replay policy, recovery mechanism, or background-lifecycle rule.
|
||||
- Do not overwrite or relabel the already-built 1.2.1 installer. Its bytes predate this
|
||||
source change, so installed-client acceptance requires a newly versioned package.
|
||||
- Concurrent and Planning Gates passed: the task identity, integration lock, exact
|
||||
source base, 211 peer records, current state, ADR-006, architecture, domain, evidence,
|
||||
reflection, commitments, and stale-item registry were checked. No semantic conflict
|
||||
was found; direct `main` promotion is ownership-blocked only.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Established the Integration Gate directly from verified source `d2ef37b`, so the
|
||||
source task record and product changes are part of the recorded base rather than
|
||||
foreign drift.
|
||||
- Added the exact source commit, confirmed installed-incident shape, shared abort then
|
||||
target-Snapshot behavior, unchanged contracts, verification, and remaining package
|
||||
gate to canonical `current-state.md`.
|
||||
- Left README, accepted ADRs, architecture, domain rules, evidence indexes, source task
|
||||
records, product code, tests, dependencies, packages, installed data, and `main`
|
||||
untouched. The resulting branch is the clean promotion candidate.
|
||||
|
||||
## Verification
|
||||
|
||||
- Source verification carried forward unchanged: 5 focused files / 35 tests, 215
|
||||
files / 1,780 full tests with 2 skips, isolated pressure 1/1, typecheck, scoped and
|
||||
full lint, Renderer/Main/Preload/utility production build, Electron E2E 1/1, and
|
||||
`git diff --check` all passed.
|
||||
- The source feature worktree remained clean at `d2ef37b`; this Integration Gate made
|
||||
no product-tree change relative to that verified base.
|
||||
- Task-aware documentation drift, project-document structure, exact product-tree,
|
||||
ancestry, and clean-status checks are run before task-context completion.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Local `main` remains owned by completed task `20260901-package-121-7a3c` and contains
|
||||
that task's untracked record. Promotion requires explicit human authorization to
|
||||
adopt, commit, and release that known task before advancing `main`.
|
||||
- After promotion, increment the application version, build and verify a new Windows
|
||||
installer, reinstall it, and repeat the reported abort interaction before claiming
|
||||
installed-client acceptance.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Resolved: canonical `current-state.md` now records the exact source, behavior,
|
||||
evidence, unchanged contracts, and package gate. No ADR, architecture, domain, or
|
||||
product-direction promotion is proposed.
|
||||
@@ -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