Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 5 additions & 83 deletions apps/desktop/electron/main/plugin-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,6 @@ export type PluginHostServices = {
exitCode: number;
/** Hex form for Windows hard-fault codes, when applicable. */
exitCodeHex?: string;
/** The plugin's own last output line, when it printed one before dying. */
lastOutput?: string;
}) => void;
/** Fired when a resident service changes supervision state. */
onServiceChange?: (status: PluginServiceStatus) => void;
Expand Down Expand Up @@ -633,14 +631,6 @@ const SERVICE_RESTART_MAX_DELAY_MS = 30_000;
const MAX_SERVICE_RESTARTS = 5;
/** A host process that stays up this long is healthy; the backoff resets. */
const SERVICE_HEALTHY_MS = 60_000;
/** Trailing plugin-output lines kept for a crash report, newest last. */
const PLUGIN_LOG_TAIL_LINES = 3;
/** Per-line cap, so a plugin that printed a megabyte cannot fill the record. */
const PLUGIN_LOG_TAIL_LINE_CHARS = 400;

/** One line a plugin host process wrote to its own stdout/stderr. */
type PluginLogLine = { level: string; message: string };

/** Convert Electron's signed Windows status into the process's unsigned code. */
function childExitUnsigned(code: number): number {
if (!Number.isFinite(code)) return code;
Expand Down Expand Up @@ -669,59 +659,6 @@ function childExitLabel(code: number): string {
return `exit code ${unsigned}${hex ? ` (${hex})` : ""}`;
}

function rememberPluginLogLine(
tail: PluginLogLine[],
level: string,
message: string,
): void {
if (!message.trim()) return;
tail.push({ level, message: message.slice(0, PLUGIN_LOG_TAIL_LINE_CHARS) });
if (tail.length > PLUGIN_LOG_TAIL_LINES) tail.shift();
}

/** Append arbitrary stream chunks while retaining complete logical lines. */
function appendPluginLogChunk(
tail: PluginLogLine[],
fragments: Map<string, string>,
level: string,
chunk: string,
): void {
const normalized = `${fragments.get(level) ?? ""}${chunk}`.replace(/\r\n?/g, "\n");
const lines = normalized.split("\n");
fragments.set(level, lines.pop() ?? "");
for (const line of lines) rememberPluginLogLine(tail, level, line);
}

/** Include a final unterminated line before a crash report snapshots the tail. */
function flushPluginLogTail(
tail: PluginLogLine[],
fragments: Map<string, string>,
): void {
for (const [level, fragment] of fragments) {
rememberPluginLogLine(tail, level, fragment);
}
fragments.clear();
}

/** The newest plugin-output line, flattened for a one-line report. */
function lastLogLine(
tail: readonly PluginLogLine[] | undefined,
): string | undefined {
const newest = tail?.[tail.length - 1];
if (!newest?.message) return undefined;
const text = newest.message.replace(/\s+/g, " ").trim();
return text ? `${newest.level}: ${text}` : undefined;
}

/** The exit code plus the plugin's last words, when it left any. */
function childExitDetail(
code: number,
tail: readonly PluginLogLine[] | undefined,
): string {
const label = childExitLabel(code);
const lastOutput = lastLogLine(tail);
return lastOutput ? `${label}; last output: ${lastOutput}` : label;
}
/** Bus payloads are messages, not file transfers. */
const MAX_BUS_PAYLOAD_BYTES = 64 * 1024;
/** A plugin may hold at most this many live subscriptions. */
Expand Down Expand Up @@ -780,10 +717,6 @@ type LoadedPlugin = {
pending: Map<string, PendingCall>;
nextCallId: number;
disposing: boolean;
/** Newest host-process output lines, so a crash report can quote them. */
logTail: PluginLogLine[];
/** Unterminated stdout/stderr fragments waiting for their newline. */
logFragments: Map<string, string>;
};

type PluginApiError = Error & { code?: string };
Expand Down Expand Up @@ -1803,18 +1736,13 @@ export class PluginRuntime {
pending: new Map(),
nextCallId: 1,
disposing: false,
logTail: [],
logFragments: new Map(),
};
this.loaded.set(manifest.id, loaded);

child.onMessage((message) => this.handleChildMessage(loaded, message));
child.onExit((code) => this.handleChildExit(loaded, code));
child.onLog?.((level, message) => {
if (!message) return;
// Stream data events are arbitrary chunks, not logical lines. Keep the
// audit shape unchanged, but only put complete lines in the crash tail.
appendPluginLogChunk(loaded.logTail, loaded.logFragments, level, message);
this.services.audit?.({
pluginId: manifest.id,
api: "plugin.stdio",
Expand Down Expand Up @@ -2827,15 +2755,11 @@ export class PluginRuntime {
if (loaded.disposing) return;
if (this.loaded.get(loaded.manifest.id) !== loaded) return;
const pluginId = loaded.manifest.id;
// The exit code is the whole diagnosis for a crash report: a Windows hard
// fault (0xC0000005 and friends) and a plugin's own `process.exit(1)` are
// different bugs, and only this number tells them apart. The plugin's last
// output line rides along because a plugin that died on a thrown error
// usually printed the reason first, and the report a user can paste is the
// one place that evidence has to survive.
flushPluginLogTail(loaded.logTail, loaded.logFragments);
const detail = childExitDetail(code, loaded.logTail);
const lastOutput = lastLogLine(loaded.logTail);
// The exit code is the diagnosis for a crash report: a Windows hard fault
// (0xC0000005 and friends) and a plugin's own process.exit(1) are different
// bugs. Do not copy plugin stdout/stderr into user-visible errors or crash
// audit records because plugin output may contain workspace data or secrets.
const detail = childExitLabel(code);
const exitCode = childExitUnsigned(code);
const exitCodeHex = childExitHex(code);
this.rejectPending(
Expand All @@ -2855,7 +2779,6 @@ export class PluginRuntime {
errorCode: "PLUGIN_CRASHED",
exitCode,
...(exitCodeHex ? { exitCodeHex } : {}),
...(lastOutput ? { message: lastOutput } : {}),
ts: Date.now(),
});
this.services.showToast(`Plugin stopped unexpectedly: ${loaded.manifest.name}`, "error");
Expand All @@ -2864,7 +2787,6 @@ export class PluginRuntime {
name: loaded.manifest.name,
exitCode,
...(exitCodeHex ? { exitCodeHex } : {}),
...(lastOutput ? { lastOutput } : {}),
});
this.superviseCrash(loaded, code);
}
Expand Down
3 changes: 1 addition & 2 deletions apps/desktop/electron/main/services/plugin-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,14 +411,13 @@ export function createPluginServices({
},
// A plugin host process dying is contained: contributions are already
// deregistered by the runtime, we only have to tell the user and the UI.
onPluginCrash: ({ pluginId, exitCode, exitCodeHex, lastOutput }) => {
onPluginCrash: ({ pluginId, exitCode, exitCodeHex }) => {
logger.app("plugin", "error", "plugin host process crashed", {
pluginId,
code: "PLUGIN_CRASHED",
data: {
exitCode,
...(exitCodeHex ? { exitCodeHex } : {}),
...(lastOutput ? { lastOutput } : {}),
},
});
// No toast here: the runtime already raised one through `showToast` on the
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/components/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ function pointForEvent(event: ReactMouseEvent<HTMLElement>): ContextMenuPoint {
* starts in this row still belongs to Copy here.
*/
function snapshotSelection(root: EventTarget): string {
// Textarea ranges are not represented by the document Selection.
const editor = document.activeElement;
if (
root instanceof Node &&
editor instanceof HTMLTextAreaElement &&
root.contains(editor)
) {
return editor.value.slice(editor.selectionStart, editor.selectionEnd);
}
const live = window.getSelection();
if (!live || live.rangeCount === 0 || live.isCollapsed) return "";
const text = live.toString();
Expand Down
49 changes: 35 additions & 14 deletions apps/desktop/src/features/chat/composer/hooks/useComposerDraft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,38 @@ export function useComposerDraft({
}
return map;
}, [activeFileReferences]);
// Native undo restores chip DOM, but deletion has already removed its metadata.
const deletedReferencesRef = useRef(new Map<string, ComposerFileReference>());
useEffect(() => {
deletedReferencesRef.current.clear();
// Observe every transition, including A -> B -> A in one React batch.
return useAppStore.subscribe((state, previous) => {
if ((state.workspace?.path ?? "") !== (previous.workspace?.path ?? "")) {
deletedReferencesRef.current.clear();
}
});
}, [draftKey]);

const reconcileEditorReferences = (text: string) => {
const current = fileReferencesRef.current;
const next = current.filter((reference) => {
if (!reference.token || text.includes(reference.token)) return true;
deletedReferencesRef.current.set(reference.token, reference);
return false;
});
// Only recover an actual restored chip, never a pasted private-use character.
for (const chip of ref.current?.querySelectorAll<HTMLElement>(".composer-chip") ?? []) {
const token = chip.dataset.token ?? "";
const reference = deletedReferencesRef.current.get(token);
if (!reference || !text.includes(token)) continue;
if (!next.some((item) => item.token === token)) next.push(reference);
deletedReferencesRef.current.delete(token);
}
if (next.length === current.length && next.every((reference, index) => reference === current[index])) return;
fileReferencesRef.current = next;
setFileReferences(next);
};

const referenceByTokenRef = useRef(referenceByToken);
referenceByTokenRef.current = referenceByToken;
const imagePreview = useComposerImagePreview({ references: fileReferences, value, sessionId: referenceSessionId, editorRef: ref });
Expand Down Expand Up @@ -273,13 +305,7 @@ export function useComposerDraft({
}
valueRef.current = nextValue;
setValue(nextValue);
setFileReferences((current) => {
const next = current.filter(
(fileReference) =>
!fileReference.token || nextValue.includes(fileReference.token),
);
return next.length === current.length ? current : next;
});
reconcileEditorReferences(nextValue);
updateCursor(caret);
return nextValue;
};
Expand All @@ -294,13 +320,7 @@ export function useComposerDraft({
editorValueRef.current = nextValue;
valueRef.current = nextValue;
setValue(nextValue);
setFileReferences((current) => {
const next = current.filter(
(fileReference) =>
!fileReference.token || nextValue.includes(fileReference.token),
);
return next.length === current.length ? current : next;
});
reconcileEditorReferences(nextValue);
updateCursor(start);
};

Expand Down Expand Up @@ -557,6 +577,7 @@ export function useComposerDraft({
deleteComposerDraft(key);
const currentKey = draftKeyForSession(useAppStore.getState().activeSessionId);
if (currentKey !== key) return;
deletedReferencesRef.current.clear();
valueRef.current = "";
if (ref.current) paintCurrentDraft(ref.current, "");
setValue("");
Expand Down
11 changes: 6 additions & 5 deletions apps/desktop/src/features/chat/transcript/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,13 @@ export const MessageRow = memo(function MessageRow({
label: t("chat.messageMenu"),
items: userMessageMenuItems({
t,
text: message.content || "",
selectTarget:
event.currentTarget.querySelector<HTMLElement>(".message-bubble"),
editable: editableUserMessage,
text: editing ? editValue : message.content || "",
selectTarget: event.currentTarget.querySelector<HTMLElement>(
editing ? ".message-edit-input" : ".message-bubble",
),
editable: editableUserMessage && !editing,
running: isRunning,
revision: showRevisionPager
revision: !editing && showRevisionPager
? { count: revisionCount, active: activeRevision }
: null,
actions: { copyText, selectText },
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/features/chat/transcript/TranscriptMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ export function useChatTextActions() {
and the row is already on screen, so nothing needs measuring.
*/
const selectText = useCallback((element: HTMLElement | null) => {
if (element instanceof HTMLTextAreaElement) {
element.focus();
element.select();
return;
}
const selection = window.getSelection();
if (!element || !selection) return;
const range = document.createRange();
Expand Down
14 changes: 3 additions & 11 deletions apps/desktop/test/plugin-services.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,6 @@ function forkPluginProcess({ entry }) {
},
onMessage: (handler) => child.on("message", handler),
onExit: (handler) => child.on("exit", (code) => handler(code ?? 0)),
// Mirrors the real spawner: the plugin's own stdout/stderr is what a crash
// report has to be able to quote.
onLog: (handler) => {
child.stdout?.on("data", (chunk) => handler("info", String(chunk).trimEnd()));
child.stderr?.on("data", (chunk) => handler("error", String(chunk).trimEnd()));
},
kill: () => child.kill(),
};
}
Expand Down Expand Up @@ -344,9 +338,6 @@ test("a crashed host process is restarted with backoff and the restart is counte
id: "worker",
start: async () => {
// Die once, right after the broker was told the service is up.
// Die once, right after the broker was told the service is up.
// The line on stderr is the fixture's own "last words", which the
// crash report has to carry (issue #747).
if (countStart() === 1) {
setTimeout(() => {
process.stderr.write("fixture older line\\nfixture service worker died");
Expand All @@ -365,14 +356,15 @@ test("a crashed host process is restarted with backoff and the restart is counte
const failed = await waitFor(() =>
runtime.getServiceStates().find((s) => s.state === "failed"),
);
// The exit code is the diagnosis: without it a report says only "it died".
// The exit code is the safe diagnosis: plugin output is not copied into
// user-visible errors or crash audit records.
assert.equal(failed.message, "plugin host process exited (exit code 7)");

const crash = await waitFor(() =>
audits.find((a) => a.api === "plugin.crash"),
);
assert.equal(crash.exitCode, 7);
assert.equal(crash.message, "error: fixture service worker died");
assert.equal("message" in crash, false);

const scheduled = await waitFor(() =>
audits.find((a) => a.api === "plugin.service.restart.scheduled"),
Expand Down
13 changes: 13 additions & 0 deletions docs/adr/0206-ten-provider-retries-and-progress-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@

## Context

### Amendment: successful response boundary (issue #699, 2026-09-20)

Desktop fault injection reproduced a long task stopping on its eleventh
independent network failure after ten successful recoveries and tool calls.
The budget previously survived successful model responses for the whole user
turn. Both retry classes now reset after a complete successful model response,
including a tool-call response, in the main runtime and builtin subagents.
Partial output, response headers, and phase changes do not reset the counters.
The ten-retry bound, separate classes, cancellation, and failed-request-only
replay remain unchanged. Exhaustion diagnostics read the appropriate counter,
not temporary activity state. This narrows the budget scope in decision 1
below without introducing a new setting or changing persisted contracts.

PI-Desktop already owns provider retries so request setup and mid-stream
failures share one counter and pi-ai does not multiply attempts through a
nested retry loop. The current budgets of five rate-limit retries and four
Expand Down
16 changes: 13 additions & 3 deletions docs/spec/03-runtime/02-agent-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ host-confirmed transition.
### 5d. Bounded provider recovery and diagnostics (D186, D245, D259, D378, ADR 0091, ADR 0128, ADR 0206)

Provider request setup and stream delivery are separate failure phases, but
HTTP 429 handling is one logical-turn policy. pi-ai's nested adapter retry is
HTTP 429 handling is one response-recovery policy. pi-ai's nested adapter retry is
disabled for this path so the runtime can share one budget across both phases.

`PROVIDER_RATE_LIMITED` receives at most ten retries after the initial
Expand Down Expand Up @@ -184,7 +184,7 @@ server or calculated value is capped at 30 seconds. The runtime captures the
failed response status and headers from fetch because pi-ai's ordinary response
callback only covers an established response.

Non-429 transient failures share their own bounded logical-turn budget of ten
Non-429 transient failures share their own bounded response-recovery budget of ten
retries after the initial attempt, for eleven provider attempts total. The budget
is shared by request setup and stream delivery, so a fault that moves between
phases cannot reset or multiply it, and it is separate from the 429 budget. It
Expand All @@ -195,6 +195,15 @@ context, and other non-retryable errors do not enter either provider replay
path, and a non-retryable `PROVIDER_ERROR` from a malformed 400/422 request
stays terminal.

Both budgets reset after a complete, non-error, non-aborted model response,
including a response that requests tools. The next model request starts with
fresh counters and backoff, even within the same user turn. Receiving HTTP
headers, partial text, or changing failure phase does not reset either budget.
This rule applies to the main session and builtin subagents: a long task with
independent recovered outages must not eventually stop because earlier tool
rounds consumed the budget. One-shot completions still use one bounded budget
for their single response. Persistent failures remain bounded and abortable.

Before surfacing a pre-stream `PROVIDER_ERROR` for HTTP 400/422 whose message
ends in `(no body)`, the runtime makes at most one silent repair attempt with
the generated output-limit fields removed: `max_tokens`,
Expand Down Expand Up @@ -241,7 +250,8 @@ When the retry budget is exhausted, the final assistant error and lifecycle
`networkSyscall`, `networkHost`, `networkRoute`) and the request correlation
(`requestMessages`, `requestBytes`, `compactionGeneration`). For a persistent
429 or non-429 transient failure,
`retryAttempt` is `10`. Credentials and unrestricted response bodies never
`retryAttempt` is `10`, derived from the exhausted error class's budget rather
than temporary retry activity state. Credentials and unrestricted response bodies never
enter the event or log. The active-turn status shows the remaining backoff and
the retry budget as `Retrying in 0s · attempt 9/10` in English.

Expand Down
Loading
Loading