fix(pi): settle delayed compact ownership
This commit is contained in:
1 parent
f5e6a04c7d
commit
9f05e2d7e1
12 files changed
+334
-25
No files matched your search
@@ -8,7 +8,7 @@
|
|||||||
- Worktree: D:\Datas\OthersProjects\makelore-pi-background-run-lease-6a4e2c91
|
- Worktree: D:\Datas\OthersProjects\makelore-pi-background-run-lease-6a4e2c91
|
||||||
- Base commit: 92f4c91088e79252aca76af3279b184da68e1eb7
|
- Base commit: 92f4c91088e79252aca76af3279b184da68e1eb7
|
||||||
- Owner: codex-root
|
- Owner: codex-root
|
||||||
- Status: Implementing follow-up
|
- Status: Ready for integration
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -133,13 +133,21 @@ Follow-up plan from cumulative HEAD `621ebb17810394f6f7b97154cb01217bc9112857`:
|
|||||||
service performs model/fork rejection before metadata persistence or fork
|
service performs model/fork rejection before metadata persistence or fork
|
||||||
target creation; sibling Conversations retain independent permits.
|
target creation; sibling Conversations retain independent permits.
|
||||||
- Manual compact uses the same retained ownership through
|
- Manual compact uses the same retained ownership through
|
||||||
`compaction_start`, `compaction_end`, authoritative settlement, and hydrate.
|
`compaction_start`, `compaction_end`, correlated RPC settlement, and
|
||||||
The final-ASAR proof now arms a controlled 12-second compact response and
|
hydrate. Locked Pi `0.84.2` does not emit `agent_settled` for a manual
|
||||||
records its running/uncertain and completed/idle states.
|
compact, so the pool now emits a target-scoped top-level settlement after
|
||||||
- Renderer submission errors retain their stable backend code. An uncertain
|
the correlated compact success (including a late success) and the runtime
|
||||||
submission keeps the draft and prevents overlap, shows the actionable delay
|
releases its lease exactly once. If `compaction_end(error)` terminalizes the
|
||||||
message instead of a runtime-outage banner, and automatically removes the
|
projection before the RPC rejection arrives, the later failure cleanup also
|
||||||
transient message when the authoritative run reaches a terminal state.
|
releases the already-terminal run lease idempotently.
|
||||||
|
- The final-ASAR proof now arms controlled 12-second Provider and Pi RPC
|
||||||
|
response delays, materializes proof-only compact thresholds in the isolated
|
||||||
|
userData, and records running/uncertain followed by completed/idle states.
|
||||||
|
- Renderer submission/action errors retain their stable backend code. An
|
||||||
|
uncertain submission keeps the draft and prevents overlap, shows the
|
||||||
|
actionable delay message instead of a runtime-outage banner, and the Header
|
||||||
|
derives away a stale compact uncertainty once a newer generation/sequence
|
||||||
|
reaches a non-uncertain terminal state.
|
||||||
Model, thinking, compact, and fork controls are disabled while the run is
|
Model, thinking, compact, and fork controls are disabled while the run is
|
||||||
active; abort and recover remain available.
|
active; abort and recover remain available.
|
||||||
|
|
||||||
@@ -181,21 +189,21 @@ Follow-up plan from cumulative HEAD `621ebb17810394f6f7b97154cb01217bc9112857`:
|
|||||||
old implementation retired the correlation and released active ownership at
|
old implementation retired the correlation and released active ownership at
|
||||||
10 seconds, returned a raw timeout, and retained the UI uncertainty after a
|
10 seconds, returned a raw timeout, and retained the UI uncertainty after a
|
||||||
completed run.
|
completed run.
|
||||||
- Focused green gate — passed: 8 files / 115 tests, including delayed prompt
|
- Focused green gate — passed: 10 files / 121 tests, including delayed prompt
|
||||||
and compact, late success/failure, settled-before-response, same-target
|
and compact, late success/failure, settled-before-response, same-target
|
||||||
mutation exclusion with zero fork/model persistence, sibling isolation,
|
mutation exclusion with zero fork/model persistence, sibling isolation,
|
||||||
safe Host error mapping, Renderer automatic unlock, and final-ASAR wiring.
|
safe Host error mapping, Renderer automatic unlock, and final-ASAR wiring.
|
||||||
- `pnpm run typecheck` — passed after the final runtime/Renderer/proof changes.
|
- `pnpm run typecheck` — passed after the final runtime/Renderer/proof changes.
|
||||||
- `pnpm run lint:check` — passed with zero errors and the same 5 pre-existing
|
- `pnpm run lint:check` — passed with zero errors and the same 5 pre-existing
|
||||||
warnings in `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`.
|
warnings in `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`.
|
||||||
- `pnpm test` — passed: 181 regular files / 1535 tests passed / 2 skipped,
|
- `pnpm test` — passed: 181 regular files / 1538 tests passed / 2 skipped,
|
||||||
followed by the isolated pressure file / 1 test passed.
|
followed by the isolated pressure file / 1 test passed.
|
||||||
- `pnpm run build:vite` — passed for Renderer, Main, Preload, and utility
|
- `pnpm run build:vite` — passed for Renderer, Main, Preload, and utility
|
||||||
bundles; only existing dynamic-import and chunk-size warnings were emitted.
|
bundles; only existing dynamic-import and chunk-size warnings were emitted.
|
||||||
- `pnpm run test:electron:windows` — passed: 2 files / 4 tests.
|
- `pnpm run test:electron:windows` — passed: 2 files / 4 tests.
|
||||||
- Final amended-HEAD NSIS, artifact closure, 12-second packaged UI/Main proof,
|
- Final clean-HEAD NSIS, artifact closure, 12-second packaged UI/Main proof,
|
||||||
Authenticode, and zero-residual-process results are completed after the task
|
Authenticode, and zero-residual-process results are run after this task
|
||||||
documentation/registry commit so the returned artifact binds to a clean
|
documentation commit so the returned artifact binds to the docs-bearing
|
||||||
cumulative HEAD.
|
cumulative HEAD.
|
||||||
- The first candidate packaged proof intentionally required
|
- The first candidate packaged proof intentionally required
|
||||||
`CODING_REQUEST_UNCERTAIN` but the controlled Provider's message-shape
|
`CODING_REQUEST_UNCERTAIN` but the controlled Provider's message-shape
|
||||||
@@ -213,6 +221,15 @@ Follow-up plan from cumulative HEAD `621ebb17810394f6f7b97154cb01217bc9112857`:
|
|||||||
through the existing Main fault-injection surface, is absent from Renderer
|
through the existing Main fault-injection surface, is absent from Renderer
|
||||||
and product configuration, and preserves the real packaged Pi command,
|
and product configuration, and preserves the real packaged Pi command,
|
||||||
session, Provider request, events, and cleanup path.
|
session, Provider request, events, and cleanup path.
|
||||||
|
- The final-ASAR candidate proof passed after the compact settlement fix:
|
||||||
|
prompt and compact each crossed the former 10-second confirmation threshold
|
||||||
|
while retaining target ownership; compact reached a durable completed node,
|
||||||
|
context/run idle, Provider active count zero, and background lease zero;
|
||||||
|
stale uncertainty disappeared from the real UI. Hidden idle eviction then
|
||||||
|
used `background_sleep`, the explicit crash/protocol/dispose matrix retained
|
||||||
|
sibling isolation and same-session recovery, and all tracked proof PIDs and
|
||||||
|
resource counters reached zero. This remains controlled loopback/fault
|
||||||
|
injection evidence with `realTurnVerified=false`.
|
||||||
|
|
||||||
- `pnpm install --frozen-lockfile` — passed with package-manager-pinned pnpm
|
- `pnpm install --frozen-lockfile` — passed with package-manager-pinned pnpm
|
||||||
`10.33.4` and locked Pi `0.84.2`.
|
`10.33.4` and locked Pi `0.84.2`.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
|
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
@@ -26,6 +26,7 @@ import type { ConversationPatchEnvelope, PrepareConversationInput } from '../con
|
|||||||
import { PiManagedExtensionHost } from './extension-host';
|
import { PiManagedExtensionHost } from './extension-host';
|
||||||
import { PiManagedInputRevisionCoordinator } from './managed-input-revision';
|
import { PiManagedInputRevisionCoordinator } from './managed-input-revision';
|
||||||
import { runPiReleasePressureCleanup } from './release-proof-cleanup';
|
import { runPiReleasePressureCleanup } from './release-proof-cleanup';
|
||||||
|
import { ensurePiManagedPaths } from './resource-loader';
|
||||||
import type { PiRpcEvent } from './rpc-client';
|
import type { PiRpcEvent } from './rpc-client';
|
||||||
import { createPiManagedWorkerOpener, PiConversationRuntime } from './runtime';
|
import { createPiManagedWorkerOpener, PiConversationRuntime } from './runtime';
|
||||||
import { PiSessionRegistry } from './session-registry';
|
import { PiSessionRegistry } from './session-registry';
|
||||||
@@ -510,7 +511,9 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const toolNames = toolNamesFrom(body);
|
const toolNames = toolNamesFrom(body);
|
||||||
const role: ProofWorkerRole = toolNames.includes('subagent') ? 'parent' : 'child';
|
const role: ProofWorkerRole = mode === 'resilience' && delayedCompactionArmed
|
||||||
|
? 'parent'
|
||||||
|
: toolNames.includes('subagent') ? 'parent' : 'child';
|
||||||
const toolResult = hasToolResult(body);
|
const toolResult = hasToolResult(body);
|
||||||
const providerRequest: ProviderRequest = {
|
const providerRequest: ProviderRequest = {
|
||||||
role,
|
role,
|
||||||
@@ -552,7 +555,7 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
|
|||||||
respondWithSubagentCall(response, model, 'coding');
|
respondWithSubagentCall(response, model, 'coding');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (role === 'parent' && delayedCompactionArmed) {
|
if (delayedCompactionArmed) {
|
||||||
delayedCompactionArmed = false;
|
delayedCompactionArmed = false;
|
||||||
const entry = { role, response } satisfies HeldProviderResponse;
|
const entry = { role, response } satisfies HeldProviderResponse;
|
||||||
held.add(entry);
|
held.add(entry);
|
||||||
@@ -1743,6 +1746,7 @@ async function waitForResilienceStatus(
|
|||||||
export async function startFinalAsarResilienceProof(input: {
|
export async function startFinalAsarResilienceProof(input: {
|
||||||
composition: CodingProductComposition;
|
composition: CodingProductComposition;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
|
userDataDir: string;
|
||||||
hostProxyBaseUrl: string;
|
hostProxyBaseUrl: string;
|
||||||
hostToken: string;
|
hostToken: string;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
@@ -1766,6 +1770,13 @@ export async function startFinalAsarResilienceProof(input: {
|
|||||||
const providerService = getProviderService();
|
const providerService = getProviderService();
|
||||||
await providerService.createAccount(proxyProviderAccount(input.hostProxyBaseUrl));
|
await providerService.createAccount(proxyProviderAccount(input.hostProxyBaseUrl));
|
||||||
await providerService.setDefaultAccount(PROOF_ACCOUNT_ID);
|
await providerService.setDefaultAccount(PROOF_ACCOUNT_ID);
|
||||||
|
const managedPaths = await ensurePiManagedPaths(input.userDataDir);
|
||||||
|
await writeFile(path.join(managedPaths.configDir, 'settings.json'), JSON.stringify({
|
||||||
|
compaction: {
|
||||||
|
reserveTokens: 128,
|
||||||
|
keepRecentTokens: 1,
|
||||||
|
},
|
||||||
|
}, null, 2), 'utf8');
|
||||||
const project = await input.composition.projects.createProject({ projectPath: input.projectPath });
|
const project = await input.composition.projects.createProject({ projectPath: input.projectPath });
|
||||||
projectId = project.project.id;
|
projectId = project.project.id;
|
||||||
await createCodingProjectAgent(input.projectPath, {
|
await createCodingProjectAgent(input.projectPath, {
|
||||||
|
|||||||
@@ -1398,6 +1398,41 @@ export class PiConversationRuntime implements CodingConversationRuntime {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (event.type === 'top-level.settled') {
|
||||||
|
void this.enqueueProjection(event.conversationId, async () => {
|
||||||
|
const snapshot = this.states.get(event.conversationId)?.snapshot;
|
||||||
|
const current = snapshot?.run;
|
||||||
|
if (!snapshot
|
||||||
|
|| snapshot.cursor.workerGeneration !== event.generation
|
||||||
|
|| current?.runId !== event.runId
|
||||||
|
|| runIsTerminal(current.status)) return;
|
||||||
|
this.emit(event.conversationId, {
|
||||||
|
op: 'run.state',
|
||||||
|
run: {
|
||||||
|
status: 'idle',
|
||||||
|
runId: event.runId,
|
||||||
|
settledAt: this.now(),
|
||||||
|
terminalReason: 'completed',
|
||||||
|
},
|
||||||
|
}, event.runId);
|
||||||
|
this.extensionUi.endRun(event.conversationId, event.runId);
|
||||||
|
try {
|
||||||
|
await Promise.allSettled([
|
||||||
|
this.interactions.cancelRun(event.conversationId, event.runId, true),
|
||||||
|
this.extensionHost?.clearRun(
|
||||||
|
event.conversationId,
|
||||||
|
event.generation,
|
||||||
|
event.runId,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
this.releaseRunBackgroundLease(event.conversationId, event.runId);
|
||||||
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
this.recordProjectionFailure(event.conversationId, event.generation, error);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.type === 'top-level.failed') {
|
if (event.type === 'top-level.failed') {
|
||||||
void this.enqueueProjection(event.conversationId, async () => {
|
void this.enqueueProjection(event.conversationId, async () => {
|
||||||
await this.failRun(
|
await this.failRun(
|
||||||
@@ -1525,8 +1560,11 @@ export class PiConversationRuntime implements CodingConversationRuntime {
|
|||||||
const current = this.states.get(conversationId)?.snapshot?.run;
|
const current = this.states.get(conversationId)?.snapshot?.run;
|
||||||
const snapshotGeneration = this.states.get(conversationId)?.snapshot?.cursor.workerGeneration;
|
const snapshotGeneration = this.states.get(conversationId)?.snapshot?.cursor.workerGeneration;
|
||||||
if (current?.runId !== runId
|
if (current?.runId !== runId
|
||||||
|| runIsTerminal(current.status)
|
|
||||||
|| (generation !== undefined && snapshotGeneration !== generation)) return;
|
|| (generation !== undefined && snapshotGeneration !== generation)) return;
|
||||||
|
if (runIsTerminal(current.status)) {
|
||||||
|
if (releaseBackgroundLease) this.releaseRunBackgroundLease(conversationId, runId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const publicError = runtimeFailure(error);
|
const publicError = runtimeFailure(error);
|
||||||
this.emit(conversationId, {
|
this.emit(conversationId, {
|
||||||
op: 'run.state',
|
op: 'run.state',
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ export type PiWorkerPoolEvent =
|
|||||||
generation: number;
|
generation: number;
|
||||||
runId: string;
|
runId: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'top-level.settled';
|
||||||
|
conversationId: string;
|
||||||
|
generation: number;
|
||||||
|
runId: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'top-level.failed';
|
type: 'top-level.failed';
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
@@ -756,6 +762,15 @@ export class PiWorkerPool {
|
|||||||
onLateResult: (result) => this.handleLateTopLevelResult(current!, run, result),
|
onLateResult: (result) => this.handleLateTopLevelResult(current!, run, result),
|
||||||
});
|
});
|
||||||
this.confirmTopLevel(current, run);
|
this.confirmTopLevel(current, run);
|
||||||
|
if (run.command.type === 'compact') {
|
||||||
|
this.settleTopLevel(current);
|
||||||
|
this.emit({
|
||||||
|
type: 'top-level.settled',
|
||||||
|
conversationId: run.conversationId,
|
||||||
|
generation: current.generation,
|
||||||
|
runId: run.runId,
|
||||||
|
});
|
||||||
|
}
|
||||||
run.resolve(response);
|
run.resolve(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const active = this.activeRuns.get(run.conversationId);
|
const active = this.activeRuns.get(run.conversationId);
|
||||||
@@ -823,6 +838,15 @@ export class PiWorkerPool {
|
|||||||
generation: record.generation,
|
generation: record.generation,
|
||||||
runId: run.runId,
|
runId: run.runId,
|
||||||
});
|
});
|
||||||
|
if (run.command.type === 'compact') {
|
||||||
|
this.settleTopLevel(record);
|
||||||
|
this.emit({
|
||||||
|
type: 'top-level.settled',
|
||||||
|
conversationId: run.conversationId,
|
||||||
|
generation: record.generation,
|
||||||
|
runId: run.runId,
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.error.code === 'PI_RPC_EXITED'
|
if (result.error.code === 'PI_RPC_EXITED'
|
||||||
|
|||||||
@@ -965,6 +965,7 @@ export async function runPiReleaseProofE2E(action: PiReleaseProofAction) {
|
|||||||
resilience: await startFinalAsarResilienceProof({
|
resilience: await startFinalAsarResilienceProof({
|
||||||
composition: codingProducts,
|
composition: codingProducts,
|
||||||
projectPath: join(app.getPath('userData'), 'pi-resilience-proof-project'),
|
projectPath: join(app.getPath('userData'), 'pi-resilience-proof-project'),
|
||||||
|
userDataDir: app.getPath('userData'),
|
||||||
hostProxyBaseUrl: `http://127.0.0.1:${address.port}/api/ai-proxy/v1`,
|
hostProxyBaseUrl: `http://127.0.0.1:${address.port}/api/ai-proxy/v1`,
|
||||||
hostToken: getHostApiToken(),
|
hostToken: getHostApiToken(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -612,6 +612,7 @@ export async function runPackagedProductProof(options) {
|
|||||||
throw new Error(`Hidden packaged Pi run was stopped despite active leases: ${JSON.stringify(backgroundHidden?.resilience)}`);
|
throw new Error(`Hidden packaged Pi run was stopped despite active leases: ${JSON.stringify(backgroundHidden?.resilience)}`);
|
||||||
}
|
}
|
||||||
await evaluateProof(electronApplication, 'resilience.release-parents');
|
await evaluateProof(electronApplication, 'resilience.release-parents');
|
||||||
|
await setMainWindowVisible(electronApplication, true);
|
||||||
await waitForResilienceProof(
|
await waitForResilienceProof(
|
||||||
electronApplication,
|
electronApplication,
|
||||||
(status) => status?.target?.runStatus === 'idle'
|
(status) => status?.target?.runStatus === 'idle'
|
||||||
@@ -619,7 +620,6 @@ export async function runPackagedProductProof(options) {
|
|||||||
&& status?.resources?.backgroundLeases?.active === 0,
|
&& status?.resources?.backgroundLeases?.active === 0,
|
||||||
'Hidden packaged Pi runs did not settle and release their background leases',
|
'Hidden packaged Pi runs did not settle and release their background leases',
|
||||||
);
|
);
|
||||||
await setMainWindowVisible(electronApplication, true);
|
|
||||||
await page.getByText('RESILIENCE_ACTIVE').last().waitFor({ state: 'visible', timeout: 30_000 });
|
await page.getByText('RESILIENCE_ACTIVE').last().waitFor({ state: 'visible', timeout: 30_000 });
|
||||||
if (!await resilienceComposer.isEnabled()) {
|
if (!await resilienceComposer.isEnabled()) {
|
||||||
throw new Error('Packaged composer stayed disabled after the hidden run settled');
|
throw new Error('Packaged composer stayed disabled after the hidden run settled');
|
||||||
|
|||||||
@@ -48,6 +48,32 @@ const THINKING_OPTIONS: Array<{ value: ConversationThinkingLevel; label: string
|
|||||||
{ value: 'high', label: '高思考' },
|
{ value: 'high', label: '高思考' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface LocalActionError {
|
||||||
|
message: string;
|
||||||
|
backendCode?: string;
|
||||||
|
startedGeneration: number;
|
||||||
|
startedSeq: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localActionError(
|
||||||
|
error: unknown,
|
||||||
|
startedGeneration: number,
|
||||||
|
startedSeq: number,
|
||||||
|
): LocalActionError {
|
||||||
|
const details = error && typeof error === 'object'
|
||||||
|
&& 'details' in error
|
||||||
|
&& error.details
|
||||||
|
&& typeof error.details === 'object'
|
||||||
|
? error.details as { backendCode?: unknown }
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
...(typeof details?.backendCode === 'string' ? { backendCode: details.backendCode } : {}),
|
||||||
|
startedGeneration,
|
||||||
|
startedSeq,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function runLabel(snapshot: ConversationSnapshot | null): string {
|
function runLabel(snapshot: ConversationSnapshot | null): string {
|
||||||
if (!snapshot) return '正在准备';
|
if (!snapshot) return '正在准备';
|
||||||
const { run } = snapshot;
|
const { run } = snapshot;
|
||||||
@@ -90,7 +116,7 @@ export function CodingConversationHeader({
|
|||||||
const vendors = useProviderStore((state) => state.vendors);
|
const vendors = useProviderStore((state) => state.vendors);
|
||||||
const options = useMemo(() => buildCodingModelOptions(accounts, vendors), [accounts, vendors]);
|
const options = useMemo(() => buildCodingModelOptions(accounts, vendors), [accounts, vendors]);
|
||||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<LocalActionError | null>(null);
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
const [titleDraft, setTitleDraft] = useState(conversation?.title ?? '');
|
const [titleDraft, setTitleDraft] = useState(conversation?.title ?? '');
|
||||||
|
|
||||||
@@ -110,13 +136,27 @@ export function CodingConversationHeader({
|
|||||||
? Math.min(100, Math.round((context.usedTokens / context.contextWindow) * 100))
|
? Math.min(100, Math.round((context.usedTokens / context.contextWindow) * 100))
|
||||||
: 0;
|
: 0;
|
||||||
const recoverable = Boolean(snapshot?.run.error?.recoverable || snapshot?.worker.error?.recoverable);
|
const recoverable = Boolean(snapshot?.run.error?.recoverable || snapshot?.worker.error?.recoverable);
|
||||||
|
const runtimeErrorCode = snapshot?.run.error?.code ?? snapshot?.worker.error?.code;
|
||||||
|
const cursorAdvancedPastAction = Boolean(actionError && snapshot && (
|
||||||
|
snapshot.cursor.workerGeneration > actionError.startedGeneration
|
||||||
|
|| (snapshot.cursor.workerGeneration === actionError.startedGeneration
|
||||||
|
&& snapshot.cursor.seq > actionError.startedSeq)
|
||||||
|
));
|
||||||
|
const visibleActionError = actionError?.backendCode === 'CODING_REQUEST_UNCERTAIN'
|
||||||
|
&& !running
|
||||||
|
&& runtimeErrorCode !== 'CODING_REQUEST_UNCERTAIN'
|
||||||
|
&& cursorAdvancedPastAction
|
||||||
|
? null
|
||||||
|
: actionError;
|
||||||
|
|
||||||
const perform = (key: string, action: () => Promise<void>) => {
|
const perform = (key: string, action: () => Promise<void>) => {
|
||||||
if (busyAction || !conversation) return;
|
if (busyAction || !conversation) return;
|
||||||
|
const startedGeneration = snapshot?.cursor.workerGeneration ?? 0;
|
||||||
|
const startedSeq = snapshot?.cursor.seq ?? 0;
|
||||||
setBusyAction(key);
|
setBusyAction(key);
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
void action()
|
void action()
|
||||||
.catch((error) => setActionError(error instanceof Error ? error.message : String(error)))
|
.catch((error) => setActionError(localActionError(error, startedGeneration, startedSeq)))
|
||||||
.finally(() => setBusyAction((current) => current === key ? null : current));
|
.finally(() => setBusyAction((current) => current === key ? null : current));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -226,7 +266,7 @@ export function CodingConversationHeader({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{actionError && <p className="border-t border-destructive/10 bg-destructive/5 px-5 py-2 text-xs text-destructive" role="alert">{actionError}</p>}
|
{visibleActionError && <p className="border-t border-destructive/10 bg-destructive/5 px-5 py-2 text-xs text-destructive" role="alert">{visibleActionError.message}</p>}
|
||||||
|
|
||||||
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
|
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import type { ProviderAccount, ProviderVendorInfo } from '@/lib/providers';
|
import type { ProviderAccount, ProviderVendorInfo } from '@/lib/providers';
|
||||||
|
import type { ConversationSnapshot } from '@/types/coding-conversation';
|
||||||
|
|
||||||
const interactionApi = vi.hoisted(() => ({
|
const interactionApi = vi.hoisted(() => ({
|
||||||
respond: vi.fn(),
|
respond: vi.fn(),
|
||||||
@@ -275,6 +276,95 @@ describe('PI-130 feature-complete Coding UI', () => {
|
|||||||
expect(screen.queryByText(/分享|回滚|待办/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/分享|回滚|待办/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears a compact confirmation uncertainty when the authoritative run settles', async () => {
|
||||||
|
interactionApi.compact.mockRejectedValueOnce(Object.assign(
|
||||||
|
new Error('请求确认延迟,可能仍在执行。请等待结果,或中止/恢复后再重试。'),
|
||||||
|
{ details: { backendCode: 'CODING_REQUEST_UNCERTAIN' } },
|
||||||
|
));
|
||||||
|
const conversation = {
|
||||||
|
id: 'conversation-compact-uncertain',
|
||||||
|
agentId: 'agent-1',
|
||||||
|
title: 'Compact uncertainty',
|
||||||
|
archivedAt: null,
|
||||||
|
unread: false,
|
||||||
|
createdAt: '2026-08-25T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-25T00:00:00.000Z',
|
||||||
|
model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'medium' as const },
|
||||||
|
modelResolution: 'resolved' as const,
|
||||||
|
};
|
||||||
|
const snapshot = (run: ConversationSnapshot['run'], seq = 1): ConversationSnapshot => ({
|
||||||
|
schemaVersion: 1,
|
||||||
|
conversation: {
|
||||||
|
id: conversation.id,
|
||||||
|
projectId: 'project-1',
|
||||||
|
agentId: conversation.agentId,
|
||||||
|
title: conversation.title,
|
||||||
|
model: { model: conversation.model, modelResolution: 'resolved' },
|
||||||
|
},
|
||||||
|
nodes: [],
|
||||||
|
run,
|
||||||
|
queue: { items: [] },
|
||||||
|
context: { usedTokens: 200, contextWindow: 1000, compaction: 'idle' },
|
||||||
|
pendingInteractions: [],
|
||||||
|
worker: { status: 'ready', generation: 1 },
|
||||||
|
cursor: { workerGeneration: 1, seq },
|
||||||
|
});
|
||||||
|
const callbacks = {
|
||||||
|
onRename: vi.fn(async () => undefined),
|
||||||
|
onArchive: vi.fn(async () => undefined),
|
||||||
|
onToggleUnread: vi.fn(async () => undefined),
|
||||||
|
onFork: vi.fn(async () => undefined),
|
||||||
|
onRefresh: vi.fn(async () => undefined),
|
||||||
|
onRecover: vi.fn(async () => undefined),
|
||||||
|
onOpenInspector: vi.fn(),
|
||||||
|
};
|
||||||
|
const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader');
|
||||||
|
const { rerender } = render(
|
||||||
|
<CodingConversationHeader
|
||||||
|
conversation={conversation}
|
||||||
|
snapshot={snapshot({ status: 'idle' })}
|
||||||
|
connectionState="live"
|
||||||
|
{...callbacks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '整理上下文' }));
|
||||||
|
expect(await screen.findByText(/请求确认延迟,可能仍在执行/)).toBeInTheDocument();
|
||||||
|
rerender(
|
||||||
|
<CodingConversationHeader
|
||||||
|
conversation={conversation}
|
||||||
|
snapshot={snapshot({
|
||||||
|
status: 'compacting',
|
||||||
|
runId: 'run-compact-uncertain',
|
||||||
|
error: {
|
||||||
|
code: 'CODING_REQUEST_UNCERTAIN',
|
||||||
|
message: '请求确认延迟,可能仍在执行。',
|
||||||
|
recoverable: true,
|
||||||
|
},
|
||||||
|
}, 2)}
|
||||||
|
connectionState="live"
|
||||||
|
{...callbacks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/请求确认延迟,可能仍在执行/)).toBeInTheDocument();
|
||||||
|
rerender(
|
||||||
|
<CodingConversationHeader
|
||||||
|
conversation={conversation}
|
||||||
|
snapshot={snapshot({
|
||||||
|
status: 'idle',
|
||||||
|
runId: 'run-compact-uncertain',
|
||||||
|
settledAt: 12_000,
|
||||||
|
terminalReason: 'completed',
|
||||||
|
}, 3)}
|
||||||
|
connectionState="live"
|
||||||
|
{...callbacks}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByText(/请求确认延迟,可能仍在执行/))
|
||||||
|
.not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
it('blocks overlapping mutations but keeps abort and recover available while confirmation is uncertain', async () => {
|
it('blocks overlapping mutations but keeps abort and recover available while confirmation is uncertain', async () => {
|
||||||
interactionApi.abort.mockResolvedValue(undefined);
|
interactionApi.abort.mockResolvedValue(undefined);
|
||||||
const recover = vi.fn(async () => undefined);
|
const recover = vi.fn(async () => undefined);
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ class LifecycleFakeWorker implements PiConversationWorker {
|
|||||||
private readonly events = new Set<(event: PiRpcEvent) => void>();
|
private readonly events = new Set<(event: PiRpcEvent) => void>();
|
||||||
private readonly invalidations = new Set<(error: PiProcessError) => void>();
|
private readonly invalidations = new Set<(error: PiProcessError) => void>();
|
||||||
private timeoutType: string | null = null;
|
private timeoutType: string | null = null;
|
||||||
|
private deferredType: string | null = null;
|
||||||
|
private deferredReject: ((error: unknown) => void) | null = null;
|
||||||
private compacted = false;
|
private compacted = false;
|
||||||
private lateResult: PiRpcRequestOptions['onLateResult'];
|
private lateResult: PiRpcRequestOptions['onLateResult'];
|
||||||
|
|
||||||
@@ -57,6 +59,12 @@ class LifecycleFakeWorker implements PiConversationWorker {
|
|||||||
)), 10_000);
|
)), 10_000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (this.deferredType === command.type) {
|
||||||
|
this.deferredType = null;
|
||||||
|
await new Promise<void>((_resolve, reject) => {
|
||||||
|
this.deferredReject = reject;
|
||||||
|
});
|
||||||
|
}
|
||||||
const data = command.type === 'get_state'
|
const data = command.type === 'get_state'
|
||||||
? {
|
? {
|
||||||
sessionId: `session-${this.id}`,
|
sessionId: `session-${this.id}`,
|
||||||
@@ -132,6 +140,15 @@ class LifecycleFakeWorker implements PiConversationWorker {
|
|||||||
this.timeoutType = type;
|
this.timeoutType = type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deferNext(type: string): void {
|
||||||
|
this.deferredType = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectDeferred(error: unknown): void {
|
||||||
|
this.deferredReject?.(error);
|
||||||
|
this.deferredReject = null;
|
||||||
|
}
|
||||||
|
|
||||||
completeLateSuccess(): void {
|
completeLateSuccess(): void {
|
||||||
this.lateResult?.({
|
this.lateResult?.({
|
||||||
response: { type: 'response', id: 'late-proof', success: true },
|
response: { type: 'response', id: 'late-proof', success: true },
|
||||||
@@ -354,7 +371,7 @@ describe('Pi run background lifecycle lease', () => {
|
|||||||
willRetry: false,
|
willRetry: false,
|
||||||
});
|
});
|
||||||
expect((await runtime.getSnapshot(runningConversation.id)).context.compaction).toBe('idle');
|
expect((await runtime.getSnapshot(runningConversation.id)).context.compaction).toBe('idle');
|
||||||
workersByConversation.get(runningConversation.id)!.emit({ type: 'agent_settled' });
|
workersByConversation.get(runningConversation.id)!.completeLateSuccess();
|
||||||
await expect.poll(async () => (
|
await expect.poll(async () => (
|
||||||
await runtime.getSnapshot(runningConversation.id)
|
await runtime.getSnapshot(runningConversation.id)
|
||||||
).run.status).toBe('idle');
|
).run.status).toBe('idle');
|
||||||
@@ -363,6 +380,31 @@ describe('Pi run background lifecycle lease', () => {
|
|||||||
);
|
);
|
||||||
expect(controller.getLeaseCount()).toBe(0);
|
expect(controller.getLeaseCount()).toBe(0);
|
||||||
|
|
||||||
|
workersByConversation.get(runningConversation.id)!.deferNext('compact');
|
||||||
|
const rejectedCompact = runtime.compact(runningConversation.id);
|
||||||
|
void rejectedCompact.catch(() => undefined);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
workersByConversation.get(runningConversation.id)!.emit({
|
||||||
|
type: 'compaction_start',
|
||||||
|
reason: 'manual',
|
||||||
|
});
|
||||||
|
workersByConversation.get(runningConversation.id)!.emit({
|
||||||
|
type: 'compaction_end',
|
||||||
|
reason: 'manual',
|
||||||
|
aborted: false,
|
||||||
|
willRetry: false,
|
||||||
|
errorMessage: 'Compaction failed: controlled rejection',
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect((await runtime.getSnapshot(runningConversation.id)).run.status).toBe('error');
|
||||||
|
workersByConversation.get(runningConversation.id)!.rejectDeferred(new PiProcessError(
|
||||||
|
'PI_RPC_RESPONSE_ERROR',
|
||||||
|
'controlled compact rejection',
|
||||||
|
{ generation: 1 },
|
||||||
|
));
|
||||||
|
await expect(rejectedCompact).rejects.toBeInstanceOf(PiProcessError);
|
||||||
|
expect(controller.getLeaseCount()).toBe(0);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(100);
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
expect(onSleep).toHaveBeenCalledTimes(1);
|
expect(onSleep).toHaveBeenCalledTimes(1);
|
||||||
expect(onStopRuntime).toHaveBeenCalledTimes(1);
|
expect(onStopRuntime).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -510,8 +510,8 @@ describe('Pi Conversation runtime', () => {
|
|||||||
expect((await runtime.getSnapshot(right.id)).run.status).toBe('error');
|
expect((await runtime.getSnapshot(right.id)).run.status).toBe('error');
|
||||||
expect(activeBackgroundLeases).toBe(0);
|
expect(activeBackgroundLeases).toBe(0);
|
||||||
await runtime.compact(right.id);
|
await runtime.compact(right.id);
|
||||||
expect((await runtime.getSnapshot(right.id)).run.status).toBe('compacting');
|
await expect.poll(async () => (await runtime.getSnapshot(right.id)).run.status).toBe('idle');
|
||||||
expect(activeBackgroundLeases).toBe(1);
|
expect(activeBackgroundLeases).toBe(0);
|
||||||
expect(workers.get(right.id)!.requests.at(-1)).toEqual({ type: 'compact' });
|
expect(workers.get(right.id)!.requests.at(-1)).toEqual({ type: 'compact' });
|
||||||
expect((await runtime.getSnapshot(left.id)).run.status).toBe('idle');
|
expect((await runtime.getSnapshot(left.id)).run.status).toBe('idle');
|
||||||
const rightDurable = {
|
const rightDurable = {
|
||||||
@@ -529,7 +529,7 @@ describe('Pi Conversation runtime', () => {
|
|||||||
durableSessions.set(right.id, rightDurable);
|
durableSessions.set(right.id, rightDurable);
|
||||||
workers.get(right.id)!.setSessionData(rightDurable);
|
workers.get(right.id)!.setSessionData(rightDurable);
|
||||||
workers.get(right.id)!.emit({ type: 'agent_end' });
|
workers.get(right.id)!.emit({ type: 'agent_end' });
|
||||||
expect((await runtime.getSnapshot(right.id)).run.status).toBe('compacting');
|
expect((await runtime.getSnapshot(right.id)).run.status).toBe('idle');
|
||||||
workers.get(right.id)!.emit({ type: 'agent_settled' });
|
workers.get(right.id)!.emit({ type: 'agent_settled' });
|
||||||
await expect.poll(async () => (await runtime.getSnapshot(right.id)).run.status).toBe('idle');
|
await expect.poll(async () => (await runtime.getSnapshot(right.id)).run.status).toBe('idle');
|
||||||
expect(activeBackgroundLeases).toBe(0);
|
expect(activeBackgroundLeases).toBe(0);
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ describe('Pi packaged release proof wiring', () => {
|
|||||||
expect(scriptSource).toContain('setMainWindowVisible(electronApplication, false)');
|
expect(scriptSource).toContain('setMainWindowVisible(electronApplication, false)');
|
||||||
expect(scriptSource).toContain("evaluateProof(electronApplication, 'resilience.dispose-target')");
|
expect(scriptSource).toContain("evaluateProof(electronApplication, 'resilience.dispose-target')");
|
||||||
expect(proofSource).toContain('const PROOF_MUTATION_CONFIRMATION_DELAY_MS = 12_000;');
|
expect(proofSource).toContain('const PROOF_MUTATION_CONFIRMATION_DELAY_MS = 12_000;');
|
||||||
|
expect(proofSource).toContain('keepRecentTokens: 1');
|
||||||
|
expect(proofSource).toContain("mode === 'resilience' && delayedCompactionArmed");
|
||||||
|
expect(mainSource).toContain("userDataDir: app.getPath('userData')");
|
||||||
expect(proofSource).toContain('delayNextTopLevelConfirmationForProof');
|
expect(proofSource).toContain('delayNextTopLevelConfirmationForProof');
|
||||||
expect(mainSource).toContain("'resilience.arm-compact-delay'");
|
expect(mainSource).toContain("'resilience.arm-compact-delay'");
|
||||||
expect(mainSource).toContain("'resilience.arm-prompt-delay'");
|
expect(mainSource).toContain("'resilience.arm-prompt-delay'");
|
||||||
@@ -47,5 +50,19 @@ describe('Pi packaged release proof wiring', () => {
|
|||||||
expect(scriptSource).toContain("evaluateProof(electronApplication, 'resilience.arm-prompt-delay')");
|
expect(scriptSource).toContain("evaluateProof(electronApplication, 'resilience.arm-prompt-delay')");
|
||||||
expect(scriptSource).toContain("status?.target?.errorCode === 'CODING_REQUEST_UNCERTAIN'");
|
expect(scriptSource).toContain("status?.target?.errorCode === 'CODING_REQUEST_UNCERTAIN'");
|
||||||
expect(scriptSource).toContain('status?.target?.completedCompactions >= 1');
|
expect(scriptSource).toContain('status?.target?.completedCompactions >= 1');
|
||||||
|
const releaseParents = scriptSource.indexOf(
|
||||||
|
"await evaluateProof(electronApplication, 'resilience.release-parents')",
|
||||||
|
);
|
||||||
|
const restoreVisible = scriptSource.indexOf(
|
||||||
|
'await setMainWindowVisible(electronApplication, true)',
|
||||||
|
releaseParents,
|
||||||
|
);
|
||||||
|
const awaitSettlement = scriptSource.indexOf(
|
||||||
|
'await waitForResilienceProof(',
|
||||||
|
releaseParents,
|
||||||
|
);
|
||||||
|
expect(releaseParents).toBeGreaterThan(-1);
|
||||||
|
expect(restoreVisible).toBeGreaterThan(releaseParents);
|
||||||
|
expect(restoreVisible).toBeLessThan(awaitSettlement);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
PiWorkerPool,
|
PiWorkerPool,
|
||||||
type PiConversationWorker,
|
type PiConversationWorker,
|
||||||
type PiWorkerOpenResult,
|
type PiWorkerOpenResult,
|
||||||
|
type PiWorkerPoolEvent,
|
||||||
} from '../../electron/coding-runtime/pi/worker-pool';
|
} from '../../electron/coding-runtime/pi/worker-pool';
|
||||||
import type { PiProcessError } from '../../electron/coding-runtime/pi/process-errors';
|
import type { PiProcessError } from '../../electron/coding-runtime/pi/process-errors';
|
||||||
import { PiProcessError as PiProcessFailure } from '../../electron/coding-runtime/pi/process-errors';
|
import { PiProcessError as PiProcessFailure } from '../../electron/coding-runtime/pi/process-errors';
|
||||||
@@ -224,6 +225,34 @@ describe('Pi worker pool', () => {
|
|||||||
expect(pool.getResilienceProofDiagnostics().runs).toEqual({ active: 0, waiting: 0 });
|
expect(pool.getResilienceProofDiagnostics().runs).toEqual({ active: 0, waiting: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('settles compact from its authoritative RPC success without agent_settled', async () => {
|
||||||
|
const worker = new FakeWorker('worker-target');
|
||||||
|
const events: PiWorkerPoolEvent[] = [];
|
||||||
|
const pool = new PiWorkerPool({
|
||||||
|
maxIdle: 2,
|
||||||
|
openWorker: async () => ({
|
||||||
|
worker,
|
||||||
|
session: { piSessionId: 'session-target', sessionKey: 'key-target' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
pool.subscribe((event) => events.push(event));
|
||||||
|
await pool.prepare(conversation('conversation-target'));
|
||||||
|
|
||||||
|
const ticket = pool.startTopLevel({
|
||||||
|
conversationId: 'conversation-target',
|
||||||
|
runId: 'run-compact-success',
|
||||||
|
command: { type: 'compact' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(ticket.accepted).resolves.toMatchObject({ success: true });
|
||||||
|
expect(pool.getActiveRun('conversation-target')).toBeNull();
|
||||||
|
expect(pool.getState('conversation-target')?.state).toBe('idle');
|
||||||
|
expect(events).toContainEqual(expect.objectContaining({
|
||||||
|
type: 'top-level.settled',
|
||||||
|
runId: 'run-compact-success',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('treats agent settlement as authoritative when the RPC confirmation is still pending', async () => {
|
it('treats agent settlement as authoritative when the RPC confirmation is still pending', async () => {
|
||||||
const worker = new FakeWorker('worker-target');
|
const worker = new FakeWorker('worker-target');
|
||||||
const pool = new PiWorkerPool({
|
const pool = new PiWorkerPool({
|
||||||
|
|||||||
Reference in new issue
Block a user