diff --git a/.project-docs/30-worklog/tasks/20260825-pi-user-entry-fork-4a7d9c21.md b/.project-docs/30-worklog/tasks/20260825-pi-user-entry-fork-4a7d9c21.md new file mode 100644 index 0000000..feadd73 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260825-pi-user-entry-fork-4a7d9c21.md @@ -0,0 +1,191 @@ +# Task: Fix Pi user-entry conversation fork + +## Identity + +- Task ID: 20260825-pi-user-entry-fork-4a7d9c21 +- Mode: Feature +- Branch: codex/20260825-pi-user-entry-fork-4a7d9c21-pi-user-entry-fork +- Worktree: D:\Datas\OthersProjects\makelore-pi-user-entry-fork-4a7d9c21 +- Base commit: 941b015330206af648f944183846ac2857468847 +- Owner: codex-root +- Status: Verifying + +## Scope + +- Restrict the Renderer fork action to durable user messages on the active + Conversation path. +- Validate the same invariant in `CodingConversationService.fork` before any + target Conversation metadata, worker, session binding, archive, or cleanup + resource can be created. +- Preserve the existing real Pi `0.84.2` fork RPC for a valid user entry and + prove the resulting target binding/hydration through unit, managed runtime, + Windows Electron, and final packaged-product seams. +- Produce and verify a new Windows x64 NSIS without modifying the user's + installed application. + +## Intent And Constraints + +- Keep the Pi hard cutover and current project/Agent/Conversation ownership. + Do not add an OpenCode fallback, runtime restart control, compatibility + layer, schema migration, feature flag, Pi upgrade, or Provider/worker-pool + redesign. +- A valid source is a message node on the source Snapshot's current active + path with the exact `sourceEntryId`, `role === 'user'`, and non-optimistic + durable state. Missing, assistant, unknown, stale, abandoned-path, or + optimistic sources fail before target creation. +- Invalid input returns existing `400 CODING_CONVERSATION_REQUEST_INVALID` + through the fixed safe Chinese route projection. Pi's raw rejection, + stderr, session paths, prompts, credentials, and provider data must not + reach Renderer or proof reports. +- Keep fork mutation no-replay: a timeout or uncertain result is never + automatically submitted again. A valid fork retains the existing source + session and hydrates a distinct target binding under the same Agent. +- Preserve cumulative base `941b015330206af648f944183846ac2857468847`, + including product parent `45d933732a8fbc0dadb07a4a4d65f0d12000c2ea` + and its Agent-owned Conversation hierarchy. Do not touch dirty `main`, the + diagnosis worktree/record, previous packaging worktrees, or user install. +- Real external Provider remains Explicitly Waived / Accepted Risk with + `realTurnVerified=false`; macOS/native Linux status is unchanged and is not + Pass evidence. Do not create subagents, push, or publish. + +## Project Context Loaded + +Task context: +- Task ID: `20260825-pi-user-entry-fork-4a7d9c21` +- Mode: feature +- Branch: `codex/20260825-pi-user-entry-fork-4a7d9c21-pi-user-entry-fork` +- Worktree: `D:\Datas\OthersProjects\makelore-pi-user-entry-fork-4a7d9c21` +- Base commit: `941b015330206af648f944183846ac2857468847` +- Other active local tasks: eleven non-ready owner records were inspected. + One AI Design E2E task has a narrow unrelated test-only scope; the occupied + `main` integration task owns historical OpenCode model-switch integration. + The remaining older planning records still contain undefined placeholder + scopes. +- Overlap or semantic-conflict assessment: no known peer owns Pi fork, + `CodingConversationTimeline`, `CodingConversationService`, or packaged Pi + fork proof semantics. Placeholder scopes remain unknown coordination state, + but their stated titles and isolated worktrees expose no semantic conflict + that changes this plan. + +Read: +- `.project-docs/05-agent-entry/read-before-planning.md` +- `.project-docs/05-agent-entry/memory-index.md` +- `.project-docs/05-agent-entry/planning-gate.md` +- this active task record +- project positioning, current state, decision index, system overview, + module map, data flow, business rules, success criteria, glossary, evidence, + reflection, commitments, and stale-item indexes +- diagnosis task `20260825-fork-runtime-unavailable-8e7c4a21` +- Pi cutover Spec and the relevant PI-050/060/100/130/150 ticket sections +- PI Conversation contracts, Renderer store, Host cutover, and cumulative + Agent→Conversation hierarchy task records +- every non-ready peer's Scope, Intent And Constraints, and Promotion + Candidates sections + +Relevant understanding: +- Project goal: Makelore Code exposes a vendor-neutral, local project-scoped + Conversation product while Electron Main exclusively owns Pi runtime, + sessions, credentials, files, and recovery. +- Current integrated focus: shared canonical memory is OpenCode-stale relative + to the cumulative Pi product. Current source, the Pi Spec/task chain, and the + committed diagnosis are authoritative for this repair. +- Active task scope: correct the supported fork source and error boundary only; + do not reinterpret a deterministic Pi entry rejection as runtime failure. +- Active constraints: target validation precedes metadata/resource creation; + valid user fork semantics and no-replay remain unchanged; the Agent-owned + Conversation hierarchy from `45d9337` must remain intact. +- Decisions affecting this task: Spec route `/api/coding/conversations/:id/fork` + is explicitly from a user entry; hydrated Snapshot nodes contain only the + active path and expose durable `sourceEntryId` without leaking Pi wire. +- Evidence, reflections, or commitments affecting this task: real pinned Pi + accepted the persisted user entry and rejected the assistant entry with + empty stderr. Windows formal packaging must retain artifact/runtime closure; + real Provider and non-Windows platform gates remain out of scope. +- Files or modules likely involved: Timeline and Chat E2E, shared/facade fork + types, `conversation-service.ts`, focused Host/runtime tests, Pi packaged + release proof, packaged smoke runner, and Windows artifact reports. +- Unknowns, stale docs, or conflicts: canonical positioning is a placeholder + and architecture/data-flow are OpenCode-stale. Several peer task scopes are + placeholders, but no known decision conflicts with the precise Pi repair. + +Gate result: +- Passed. + +## Plan + +1. Add failing Renderer and Main regressions for user-only visibility, exact + callback identity, invalid active-path rejection, and zero target-side + effects; verify they fail on the diagnosed behavior. +2. Implement the smallest product-contract correction: user-only durable UI + action and service-owned active-Snapshot validation before target create. +3. Add valid user fork coverage through the real pinned Pi managed opener and + service seam, checking distinct target binding, Pi session identity, + hydrated Snapshot, source isolation, and clean worker shutdown. +4. Extend Windows Electron E2E and final packaged Main/UI proof so assistant + has no action, the user action creates/selects a same-Agent branch, the + original remains unchanged, and no runtime-unavailable state or process + residue appears. +5. Run frozen install, focused tests, typecheck, lint, full unit, production + build, Windows Electron E2E, then commit a clean candidate and run formal + `package:win`, Windows/Pi closure verifiers, packaged proof, signing/hash, + documentation drift, registry completion, and final clean-state checks. + +## Outcome + +- Renderer now exposes the fork action only on non-optimistic user message + nodes with a durable `sourceEntryId`; assistant and non-persisted nodes do + not expose the action. +- `CodingConversationService.fork` now resolves the source Conversation's + authoritative hydrated Snapshot and rejects missing, assistant, unknown, + stale, or abandoned-path entries with + `400 CODING_CONVERSATION_REQUEST_INVALID` before target metadata creation. + The Host keeps its fixed safe Chinese projection and never exposes Pi's raw + `Invalid entry ID for forking` response. +- A valid user entry still reaches the locked Pi fork RPC. The permanent real + Pi `0.84.2` regression proves source hydration, a same-Agent target with a + distinct Pi session binding, correct before-entry target hydration, source + isolation, and clean shutdown of both workers. +- Windows Electron coverage now proves one user fork action for a history that + also contains an assistant entry, exact `sourceEntryId` submission, target + selection under the same Agent, preserved source history, and absence of + the runtime-unavailable banner. +- The final packaged proxy proof now performs the user-entry fork through the + real packaged Main composition and UI after a settled controlled turn. It + checks distinct bindings, correct target hydrate, source preservation, + zero prompt replay, user/assistant action visibility, process cleanup, and + the existing credential/role-contract boundaries. +- `README.md` now states that only persisted user messages are fork sources. + No Provider, worker-pool, schema, compatibility, or fallback architecture + changed. + +## Verification + +- `corepack pnpm install --frozen-lockfile` — passed with package-manager + pinned pnpm `10.33.4` and Pi `0.84.2`. +- Red phase — the focused Timeline/Main tests failed as expected: assistant + exposed a second fork action, and invalid entries reached target creation. +- Focused green — `4` files / `29` tests passed, including the real managed Pi + service fork and release-proof wiring. +- `corepack pnpm run typecheck` — passed. +- `corepack pnpm run lint:check` — passed with the existing five warnings and + no errors. +- `corepack pnpm test` — passed: `180` files / `1525` tests plus the isolated + pressure test; `2` pre-existing skips. +- `corepack pnpm run build:vite` — passed for Renderer, Main, Preload, and + release utility worker. +- Focused Windows Electron Playwright — passed: `2` tests, including the + same-Agent user-entry branch flow and source isolation. +- `corepack pnpm run test:electron:windows` — passed: `2` files / `4` tests. +- Formal Windows package, artifact closure, packaged UI/Main fork proof, + signing, fingerprint, and zero-residue evidence remain the active gate. + +## Follow-ups + +- Real external Provider execution remains explicitly waived and is not proof + for this fix (`realTurnVerified=false`). macOS and native non-WSL Linux + states remain unchanged. + +## Promotion Candidates + +- None. The current Pi Spec already states that fork is from a user entry; the + product README and this task-owned record now match that existing decision. diff --git a/README.md b/README.md index 541d3f4..148c5db 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - Makelore 在应用侧按 Session 独立提交、跟踪和隔离运行状态,不使用“当前对话正在回复”的全局界面锁;同一 Session 的后续消息仍按顺序排队。最终产品中的 Pi 运行时会通过受控 Provider-shaped 回环 smoke 验证两个 worker 的重叠执行、状态隔离与凭证引用隔离;真实外部 Provider 的并发、限流、协议兼容和凭证隔离仍是独立风险,未执行真实 Provider 验证时不得标记为 Pass。 - 首次发送会立即生成稳定的乐观用户消息;HTTP 202 只表示本地 Agent 已接收。后续失败不会删除已接受消息,不确定交付不会自动重发;准备失败可在目标 Conversation 上手动恢复。 - 运行中的 Conversation 可把新消息作为 steer 引导当前回答或 follow-up 排到下一轮,并显示队列位置;队列只在 `agent_settled` 后释放,用户可中止当前运行。select/confirm/input/editor 交互在输入区上方回答,并明确展示取消或失效结果。 -- 核心聊天页支持标题、归档、未读、恢复,以及“从这里创建新对话分支”;分支只创建新的 Conversation 历史,不表示文件回滚。右侧编程工具集中展示当前 Conversation 的 changes、项目文件预览、浏览器附件、技能、命令与脱敏运行诊断。分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。 +- 核心聊天页支持标题、归档、未读、恢复,以及从已持久化的 user 消息“从这里创建新对话分支”;assistant 消息和未持久化消息不提供该动作。分支只创建新的 Conversation 历史,不表示文件回滚。右侧编程工具集中展示当前 Conversation 的 changes、项目文件预览、浏览器附件、技能、命令与脱敏运行诊断。分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。 ## 兼容标识 diff --git a/electron/coding-runtime/conversation-service.ts b/electron/coding-runtime/conversation-service.ts index 93c105b..2594edb 100644 --- a/electron/coding-runtime/conversation-service.ts +++ b/electron/coding-runtime/conversation-service.ts @@ -491,7 +491,27 @@ export class CodingConversationService { } async fork(sourceConversationId: string, sourceEntryId?: string): Promise { + const entryId = requiredString(sourceEntryId, 'Fork source entry id', 256); const prepared = await this.ensurePrepared(sourceConversationId); + let sourceSnapshot: ConversationSnapshot; + try { + sourceSnapshot = await this.runtime.getSnapshot(sourceConversationId); + } catch (error) { + runtimeError(error); + } + const sourceNode = sourceSnapshot.nodes.find((node) => ( + node.kind === 'message' + && node.sourceEntryId === entryId + && node.role === 'user' + && node.status !== 'optimistic' + )); + if (!sourceNode) { + throw new CodingConversationServiceError( + 400, + 'CODING_CONVERSATION_REQUEST_INVALID', + 'Fork source must be a durable user message on the active Conversation path', + ); + } const source = await this.projects.findActiveConversation(sourceConversationId); const store = this.projects.conversationStore(source.project.path); const created = await persist(() => store.create({ @@ -503,7 +523,7 @@ export class CodingConversationService { try { await this.runtime.fork({ sourceConversationId, - ...(sourceEntryId ? { sourceEntryId } : {}), + sourceEntryId: entryId, conversation: { ...prepared, conversationId: created.id, diff --git a/electron/coding-runtime/pi/release-proof.ts b/electron/coding-runtime/pi/release-proof.ts index 9855383..3c017f3 100644 --- a/electron/coding-runtime/pi/release-proof.ts +++ b/electron/coding-runtime/pi/release-proof.ts @@ -104,10 +104,12 @@ type ProxyCompositionRun = { provider: LocalProofProvider; projectId: string; projectPath: string; + sourceConversationId?: string; hostToken: string; originalAcceptPrompt: CodingProductComposition['conversations']['acceptPrompt']; submissionInjection: { definiteRejections: number }; activeStatus?: PiReleaseProxyCompositionStatus; + settledStatus?: PiReleaseProxyCompositionStatus; }; type ResilienceCompositionRun = { @@ -212,6 +214,18 @@ export interface PiReleaseProxyCompositionProof extends PiReleaseProxyCompositio definiteRejectionObserved: boolean; retryAccepted: boolean; }; + fork: { + conversationCount: number; + sameAgent: boolean; + targetBindingEstablished: boolean; + distinctSessionBinding: boolean; + workerStatus: string; + runStatus: string; + sourceEntryRole: 'user'; + targetHydratedBeforeSourceEntry: boolean; + sourceUnchanged: boolean; + promptReplayObserved: boolean; + }; currentHostTokenUsedBy: ProofWorkerRole[]; released: { workers: number }; } @@ -916,8 +930,12 @@ async function proxyCompositionStatus( .filter(({ role }) => role === 'child') .map(({ processId }) => processId) .sort((left, right) => left - right); + const conversations = await run.composition.conversations.listConversations(run.projectId); + if (!run.sourceConversationId && conversations.length === 1) { + run.sourceConversationId = conversations[0]?.id; + } return { - conversationCount: (await run.composition.conversations.listConversations(run.projectId)).length, + conversationCount: conversations.length, providerRequests, activeProviderRequests: run.provider.activeCounts(), workers: diagnostics.workers, @@ -1442,6 +1460,12 @@ export async function getFinalAsarProxyCompositionStatus(): Promise 0) { run.activeStatus = status; } + if (status.conversationCount === 1 + && status.activeProviderRequests.parent === 0 + && status.activeProviderRequests.child === 0 + && status.providerCompatibility.systemRoleRequests >= 2) { + run.settledStatus = status; + } return status; } @@ -1456,10 +1480,12 @@ export async function finishFinalAsarProxyCompositionProof(): Promise id === run.sourceConversationId); + const forked = conversations.find(({ id }) => id !== run.sourceConversationId); + if (!conversation || !forked) throw new Error('Proxy fork source or target Conversation is unavailable'); let snapshot = await run.composition.conversations.getSnapshot(conversation.id); const deadline = Date.now() + 30_000; while (Date.now() < deadline && ( @@ -1473,9 +1499,24 @@ export async function finishFinalAsarProxyCompositionProof(): Promise ( + node.kind === 'message' + && node.role === 'user' + && node.blocks.some((block) => block.kind === 'text' + && block.text.includes('Retry the packaged Main proxy Conversation')) + )); + if (!sourceEntry || sourceEntry.kind !== 'message' || !sourceEntry.sourceEntryId) { + throw new Error('Proxy source user entry is not durable after the controlled turn'); + } + const [binding, forkedBinding] = await Promise.all([ + run.composition.projects.conversationStore(run.projectPath).get(conversation.id), + run.composition.projects.conversationStore(run.projectPath).get(forked.id), + ]); const finalStatus = await proxyCompositionStatus(run); const activeStatus = run.activeStatus ?? finalStatus; + const settledStatus = run.settledStatus; + if (!settledStatus) throw new Error('Proxy source Conversation did not record a settled pre-fork baseline'); const currentHostTokenUsedBy: ProofWorkerRole[] = [ ...(activeStatus.providerRequests.parent > 0 ? ['parent' as const] : []), ...(activeStatus.providerRequests.child > 0 ? ['child' as const] : []), @@ -1486,6 +1527,27 @@ export async function finishFinalAsarProxyCompositionProof(): Promise ( + node.kind !== 'message' || node.sourceEntryId !== sourceEntry.sourceEntryId + )) && !JSON.stringify(forkedSnapshot.nodes).includes('REAL_PARENT_COMPLETE'); + if (forked.agentId !== conversation.agentId + || !distinctSessionBinding + || forkedSnapshot.worker.status !== 'ready' + || forkedSnapshot.run.status !== 'idle' + || !targetHydratedBeforeSourceEntry) { + throw new Error(`Proxy user-entry fork proof failed: ${JSON.stringify({ + sameAgent: forked.agentId === conversation.agentId, + distinctSessionBinding, + workerStatus: forkedSnapshot.worker.status, + runStatus: forkedSnapshot.run.status, + targetHydratedBeforeSourceEntry, + })}`); + } if (!finalStatus.providerCompatibility.roleContract.developerRejected || !finalStatus.providerCompatibility.roleContract.systemAccepted || finalStatus.providerCompatibility.developerRoleRequests !== 0 @@ -1512,7 +1574,7 @@ export async function finishFinalAsarProxyCompositionProof(): Promise= 2, }, + fork: { + conversationCount: conversations.length, + sameAgent: forked.agentId === conversation.agentId, + targetBindingEstablished: true, + distinctSessionBinding, + workerStatus: forkedSnapshot.worker.status, + runStatus: forkedSnapshot.run.status, + sourceEntryRole: 'user', + targetHydratedBeforeSourceEntry, + sourceUnchanged: serializedNodes.includes('Retry the packaged Main proxy Conversation') + && serializedNodes.includes('REAL_PARENT_COMPLETE'), + promptReplayObserved: finalStatus.providerRequests.parent !== settledStatus.providerRequests.parent + || finalStatus.providerRequests.child !== settledStatus.providerRequests.child, + }, currentHostTokenUsedBy, }; await cleanupProxyCompositionRun(run); diff --git a/scripts/run-pi-subagent-packaged-smoke.mjs b/scripts/run-pi-subagent-packaged-smoke.mjs index a9f7c79..209d654 100644 --- a/scripts/run-pi-subagent-packaged-smoke.mjs +++ b/scripts/run-pi-subagent-packaged-smoke.mjs @@ -164,6 +164,16 @@ function assertProxyProof(proof) { || proof?.firstConversation?.parentResponseProjected !== true || proof?.firstConversation?.definiteRejectionObserved !== true || proof?.firstConversation?.retryAccepted !== true + || proof?.fork?.conversationCount !== 2 + || proof?.fork?.sameAgent !== true + || proof?.fork?.targetBindingEstablished !== true + || proof?.fork?.distinctSessionBinding !== true + || proof?.fork?.workerStatus !== 'ready' + || proof?.fork?.runStatus !== 'idle' + || proof?.fork?.sourceEntryRole !== 'user' + || proof?.fork?.targetHydratedBeforeSourceEntry !== true + || proof?.fork?.sourceUnchanged !== true + || proof?.fork?.promptReplayObserved !== false || proof?.providerCompatibility?.developerRoleRequests !== 0 || proof?.providerCompatibility?.controlledDefiniteRejections !== 1 || proof?.currentHostTokenUsedBy?.join(',') !== 'parent,child' @@ -460,6 +470,43 @@ export async function runPackagedProductProof(options) { } if (!await composer.isEnabled()) throw new Error('Packaged proxy composer is not editable after the turn'); + const settledProxy = await evaluateProof(electronApplication, 'proxy.status'); + if (settledProxy?.proxy?.conversationCount !== 1 + || settledProxy?.proxy?.activeProviderRequests?.parent !== 0 + || settledProxy?.proxy?.activeProviderRequests?.child !== 0) { + throw new Error(`Packaged proxy source did not settle before fork: ${JSON.stringify(settledProxy?.proxy)}`); + } + const timeline = page.getByTestId('coding-conversation-timeline'); + const forkActions = timeline.getByRole('button', { name: '从这里创建新对话分支' }); + await forkActions.waitFor({ state: 'visible', timeout: 30_000 }); + if (await forkActions.count() !== 1) { + throw new Error(`Packaged proxy timeline exposed ${await forkActions.count()} fork actions for one user and one assistant entry`); + } + const sourceHeader = await page.getByTestId('coding-conversation-header').innerText(); + await forkActions.click({ timeout: 30_000 }); + await page.waitForFunction((previous) => { + const header = document.querySelector('[data-testid="coding-conversation-header"]'); + return Boolean(header?.textContent?.includes('(fork)') && header.textContent !== previous); + }, sourceHeader, { timeout: 30_000 }); + const forkedHeader = await page.getByTestId('coding-conversation-header').innerText(); + const forkedTitle = forkedHeader.split('\n', 1)[0]?.trim(); + if (!forkedTitle?.includes('(fork)')) { + throw new Error(`Packaged proxy fork target was not selected: ${forkedHeader}`); + } + const agentConversations = page.getByRole('group', { name: 'Packaged proxy proof agent 的对话' }); + await agentConversations.getByRole('button', { name: forkedTitle, exact: true }) + .waitFor({ state: 'visible', timeout: 30_000 }); + const forkedBody = await page.locator('body').innerText(); + if (forkedBody.includes('本地编程运行时暂时不可用') + || forkedBody.includes('正在重新连接本地 Agent')) { + throw new Error(`Packaged user-entry fork exposed a runtime banner: ${forkedBody}`); + } + if (await timeline.getByText(retryPrompt, { exact: true }).count() !== 0 + || await timeline.getByText('REAL_PARENT_COMPLETE', { exact: true }).count() !== 0 + || await timeline.getByRole('button', { name: '从这里创建新对话分支' }).count() !== 0) { + throw new Error('Packaged fork target did not hydrate at the path before the selected user entry'); + } + const proxyFinish = await evaluateProof(electronApplication, 'proxy.finish'); proxyActive = false; assertPackagedMain(proxyFinish); @@ -653,6 +700,12 @@ export async function runPackagedProductProof(options) { submissionErrorSafe, retryAfterEditEnabled, retrySubmitted: true, + assistantForkActionHidden: true, + userForkActionAvailable: true, + forkTargetSelected: true, + sameAgentOwnership: true, + sourceUnchanged: proxyFinish.proxy?.fork?.sourceUnchanged === true, + uncertainPromptReplayed: proxyFinish.proxy?.fork?.promptReplayObserved === true, runtimeUnavailableBanner: false, permanentRecovering: false, }, diff --git a/src/pages/Chat/CodingConversationTimeline.tsx b/src/pages/Chat/CodingConversationTimeline.tsx index f83a5f2..892ec39 100644 --- a/src/pages/Chat/CodingConversationTimeline.tsx +++ b/src/pages/Chat/CodingConversationTimeline.tsx @@ -187,7 +187,7 @@ const MessageNode = memo(function MessageNode({ 本轮已中止。

)} - {node.sourceEntryId && onFork && node.status !== 'optimistic' && ( + {user && node.sourceEntryId && onFork && node.status !== 'optimistic' && (