Skip to content
Open
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
6 changes: 4 additions & 2 deletions src/main/db/projectsThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getDb } from "./connection";
import { forgetMainCreatedThread, noteMainCreatedThread } from "./mainCreatedThreads";
import { notifyProjectThreadDataChanged } from "./projectThreadChanges";
import { projectMutableRow, rowToProject, rowToThread } from "./rowMappers";
import { dbInterruptStaleDelegatedAgents } from "./runtimeItems";

// ── Public query functions (called from IPC handlers) ───────────────

Expand Down Expand Up @@ -176,8 +177,8 @@ export function dbSetThreadGroup(threadId: string, groupId: string, groupName: s
}

/**
* No agent session survives a host restart, so any persisted live status
* ("launching"/"working"/...) is stale by definition once the process boots.
* No agent session survives a host restart, so persisted live thread status
* and running delegated-agent rows are stale once the process boots.
* DB-level counterpart of the renderer's `markThreadsInactiveOnLaunch`; the
* headless server calls it at startup since it has no renderer to self-heal.
*/
Expand All @@ -187,6 +188,7 @@ export function dbMarkLiveThreadsInactive(): void {
.set({ status: "inactive", attention: "none", activeTurnStartedAt: null })
.where(notInArray(schema.threads.status, ["inactive", "error"]))
.run();
dbInterruptStaleDelegatedAgents();
notifyProjectThreadDataChanged();
}

Expand Down
82 changes: 82 additions & 0 deletions src/main/db/runtimeItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
dbGetLatestThreadRuntimeAnchorItemId,
dbGetThreadRuntimeItems,
dbGetThreadRuntimeItemsPage,
dbInterruptStaleDelegatedAgents,
dbReplaceThreadRuntimeItems,
dbTruncateThreadRuntimeAfter,
} from "./runtimeItems";
Expand Down Expand Up @@ -131,6 +132,87 @@ describe.skipIf(!sqliteAvailable)("runtimeItems incremental persistence", () =>
});
});

it("durably interrupts delegated agents left running by the previous app process", () => {
dbReplaceThreadRuntimeItems("thread-1", [
{
id: "subagent-running",
type: "tool_call",
state: "updated",
payload: { name: "Task", status: "running", isSubAgent: true },
streams: {},
},
{
id: "crossagent-running",
type: "tool_call",
state: "completed",
payload: {
name: "Crossagent",
status: "running",
isCrossagent: true,
crossagentStatus: "running",
},
streams: {},
},
{
id: "raw-crossagents-mcp",
type: "tool_call",
state: "started",
payload: {
name: "mcp__crossagents__spawn_agent",
status: "running",
isSubAgent: true,
},
streams: {},
},
{
id: "ordinary-tool",
type: "tool_call",
state: "started",
payload: { name: "Read", status: "running" },
streams: {},
},
{
id: "subagent-complete",
type: "tool_call",
state: "completed",
payload: { name: "Task", status: "success", isSubAgent: true },
streams: {},
},
]);

expect(dbInterruptStaleDelegatedAgents()).toBe(2);
expect(dbInterruptStaleDelegatedAgents()).toBe(0);

const byId = new Map(dbGetThreadRuntimeItems("thread-1").map((item) => [item.id, item]));
expect(byId.get("subagent-running")).toMatchObject({
state: "completed",
payload: {
status: "error",
result: { error: "Interrupted: agent session ended before completion." },
},
});
expect(byId.get("crossagent-running")).toMatchObject({
state: "completed",
payload: {
status: "error",
crossagentStatus: "failed",
result: { error: "Interrupted: agent session ended before completion." },
},
});
expect(byId.get("raw-crossagents-mcp")).toMatchObject({
state: "started",
payload: { status: "running" },
});
expect(byId.get("ordinary-tool")).toMatchObject({
state: "started",
payload: { status: "running" },
});
expect(byId.get("subagent-complete")).toMatchObject({
state: "completed",
payload: { status: "success" },
});
});

it("deduplicates repeated item starts and removes empty completed reasoning", () => {
const start = {
type: "item.started" as const,
Expand Down
55 changes: 54 additions & 1 deletion src/main/db/runtimeItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import Database from "better-sqlite3";
import type { RuntimeEvent, ThreadContextUsage, ToolCallPayload } from "@/shared/contracts";
import { RUNTIME_REQUEST_ITEM_TYPE } from "@/shared/contracts";
import { inlineImagePayloadRenders } from "@/shared/inlineImagePayload";
import { msg } from "@/shared/messages";
import type { PersistedRuntimePage } from "@/shared/ipc/schemas";
import { isSubAgentTool } from "@/shared/toolCallClassification";
import {
interruptDelegatedAgentToolPayload,
isDelegatedAgentTool,
isSubAgentTool,
} from "@/shared/toolCallClassification";
import { getSqlite } from "./connection";
import { safeParse } from "./rowMappers";

Expand Down Expand Up @@ -190,6 +195,54 @@ export function dbGetThreadRuntimeItems(threadId: string): PersistedRuntimeItem[
return rows.map(mapRuntimeItemRow);
}

/**
* No delegated-agent process survives a host restart. Persist the same terminal
* state the renderer applies during hydration so remote snapshots cannot keep
* advertising pre-restart Subagents or Crossagents as live.
*/
export function dbInterruptStaleDelegatedAgents(): number {
const sqlite = getSqlite();
const candidates = sqlite
.prepare(
`SELECT thread_id, item_id, state, payload
FROM thread_runtime_items
WHERE type = 'tool_call'
AND (state != 'completed' OR payload LIKE '%"status"%running%')`,
)
.all() as Array<{
thread_id: string;
item_id: string;
state: string;
payload: string | null;
}>;
const update = sqlite.prepare(
`UPDATE thread_runtime_items
SET state = 'completed', payload = ?
WHERE thread_id = ? AND item_id = ?`,
);

return sqlite.transaction(() => {
let interrupted = 0;
for (const candidate of candidates) {
const parsed = candidate.payload ? safeParse(candidate.payload) : undefined;
const payload =
parsed && typeof parsed === "object" ? (parsed as ToolCallPayload) : undefined;
if (!payload || !isDelegatedAgentTool(payload)) continue;
if (candidate.state === "completed" && payload.status !== "running") continue;
const nextPayload = interruptDelegatedAgentToolPayload(
payload,
msg("runtime.delegatedAgentInterrupted"),
);
interrupted += update.run(
JSON.stringify(nextPayload),
candidate.thread_id,
candidate.item_id,
).changes;
}
return interrupted;
})();
}

/**
* Reads one runtime item by id. Exists so the remote image endpoint can resolve
* a single inline image without loading a whole thread's payloads — a
Expand Down
2 changes: 2 additions & 0 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
dbGetThreads,
dbInsertScheduleRun,
dbInterruptScheduleRuns,
dbMarkLiveThreadsInactive,
dbUpdateScheduleRun,
dbUpsertThread,
initDatabase,
Expand Down Expand Up @@ -703,6 +704,7 @@ if (!hasSingleInstanceLock) {
}

initDatabase(paths.dbPath);
dbMarkLiveThreadsInactive();
const secretStorageKey = readOrCreateSafeStorageSecretKey(paths.baseDir);
// Configure the same key in main so it can seal captured secrets (e.g. usage
// login cookies); the supervisor configures it from the env var it receives.
Expand Down
12 changes: 10 additions & 2 deletions src/mobile/remoteSocketCoordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,12 @@ function createClient(): ClientMock {
};
}

function createHarness(input: { readonly initialLastSeenSeq?: number } = {}): Harness {
function createHarness(
input: {
readonly initialLastSeenSeq?: number;
readonly onEventStreamReset?: () => void;
} = {},
): Harness {
const client = createClient();
let selectedThreadId: string | null = "selected";
const createClientMock = vi.fn<() => ClientMock>(() => client);
Expand All @@ -153,6 +158,7 @@ function createHarness(input: { readonly initialLastSeenSeq?: number } = {}): Ha
onConnectionChange,
onMessageChange,
onOpenChange,
...(input.onEventStreamReset ? { onEventStreamReset: input.onEventStreamReset } : {}),
getPairingExpiredMessage: () => "localized pairing fallback",
});
return {
Expand Down Expand Up @@ -355,7 +361,8 @@ describe("remoteSocketCoordinator", () => {
});

it("resets a stale cursor after a server restart and accepts the new event stream", async () => {
const harness = track(createHarness({ initialLastSeenSeq: 42 }));
const onEventStreamReset = vi.fn<() => void>();
const harness = track(createHarness({ initialLastSeenSeq: 42, onEventStreamReset }));
const socket = await start(harness);
socket.open();

Expand All @@ -365,6 +372,7 @@ describe("remoteSocketCoordinator", () => {
reason: "Server event stream reset; request a fresh snapshot.",
});
expect(harness.coordinator.getLastSeenSeq()).toBe(0);
expect(onEventStreamReset).toHaveBeenCalledOnce();

socket.message({
type: "event",
Expand Down
2 changes: 2 additions & 0 deletions src/mobile/remoteSocketCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface RemoteSocketCoordinatorOptions {
readonly onConnectionChange: (state: SocketConnectionState) => void;
readonly onMessageChange: (message: string) => void;
readonly onOpenChange: (open: boolean) => void;
readonly onEventStreamReset?: () => void;
/** Recent HTTP success lets scheduleReconnect keep the pill "online" while
* only the event socket is down. Optional; defaults to always-unhealthy. */
readonly isHttpHealthy?: () => boolean;
Expand Down Expand Up @@ -256,6 +257,7 @@ export function createRemoteSocketCoordinator(
// The server's in-memory sequence can move backwards after a
// desktop restart. Reset immediately so events from the new
// stream are not discarded while the recovery snapshot loads.
if (parsed.seq < lastSeenSeq) options.onEventStreamReset?.();
lastSeenSeq = parsed.seq;
scheduleRefresh({ recovery: true, resetLastSeenSeq: true });
}
Expand Down
50 changes: 50 additions & 0 deletions src/mobile/storeSync.applyThreadSnapshot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,56 @@ describe("applyThreadSnapshot", () => {
expect(assistantStreamText("msg-1")).toBe("newer live text");
});

it("accepts terminal delegated-agent state without clobbering newer live text", () => {
const store = useAppStore.getState();
store.applyRuntimeEvents(THREAD_ID, [
{ type: "item.started", threadId: THREAD_ID, itemId: "msg-1", itemType: "assistant_message" },
{
type: "content.delta",
threadId: THREAD_ID,
itemId: "msg-1",
stream: "assistant_text",
delta: "newer live text",
},
{
type: "item.started",
threadId: THREAD_ID,
itemId: "subagent-old",
itemType: "tool_call",
payload: { name: "Task", status: "running", isSubAgent: true },
},
]);

applyThreadSnapshot(
makeSnapshot({
status: "working",
items: [
makeItem({ id: "msg-1", assistantText: "older snapshot text" }),
{
id: "subagent-old",
type: "tool_call",
state: "completed",
payload: {
name: "Task",
status: "error",
isSubAgent: true,
result: { error: "Interrupted: agent session ended before completion." },
},
streams: {},
},
],
}),
);

expect(assistantStreamText("msg-1")).toBe("newer live text");
expect(
useAppStore.getState().runtimeItemsByIdByThread[THREAD_ID]?.["subagent-old"],
).toMatchObject({
state: "completed",
payload: { status: "error" },
});
});

it("does not overwrite a newer live payload with an active recovery snapshot", () => {
const store = useAppStore.getState();
store.applyRuntimeEvents(THREAD_ID, [
Expand Down
4 changes: 4 additions & 0 deletions src/mobile/useRemoteDesktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,10 @@ export function useRemoteDesktop() {
onOpenChange: (open) => {
socketOpenRef.current = open;
},
onEventStreamReset: () => {
const threadId = selectedThreadIdRef.current;
if (threadId) useAppStore.getState().clearThreadRuntimeEvents(threadId);
},
isHttpHealthy: () => Date.now() - lastRefreshOkAtRef.current < 45_000,
getPairingExpiredMessage: () => i18n._(msg`Pairing expired — pair again to reconnect.`),
});
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/i18n/sharedMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ const SHARED_MESSAGE_DESCRIPTORS: Record<MessageKey, MessageDescriptor> = {
"supervisor.exited": msg({ message: "Background process exited unexpectedly" }),
"supervisor.notRunning": msg({ message: "Background process is not running" }),
"supervisor.proposedPlan": msg({ message: "Proposed plan" }),
"runtime.delegatedAgentInterrupted": msg({
message: "Interrupted: agent session ended before completion.",
}),
"kimi.credentialsLocked": msg({
message:
"Kimi Code could not update its credentials because another process is using the credential file. Close other Poracode or Kimi Code processes, then retry.",
Expand Down
10 changes: 9 additions & 1 deletion src/renderer/locales/de/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -4827,6 +4827,10 @@ msgstr "Dateien"
msgid "Files for {0}"
msgstr "Dateien für {0}"

#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx
msgid "Files for {projectName}"
msgstr "Dateien für {projectName}"

#: src/renderer/views/SettingsOverlay/parts/SearchExcludeBody.tsx
#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts
msgid "Files matching these globs are hidden from the @file mention search."
Expand Down Expand Up @@ -5864,7 +5868,7 @@ msgstr "Anweisungen, denen Agenten folgen, wenn sie auswählen, an welchen Agent
msgid "Interrupted"
msgstr "Unterbrochen"

#: src/renderer/state/slices/runtimeEventSlice.ts
#: src/renderer/i18n/sharedMessages.ts
msgid "Interrupted: agent session ended before completion."
msgstr "Unterbrochen: Agentensitzung wurde vorzeitig beendet."

Expand Down Expand Up @@ -11391,6 +11395,10 @@ msgstr "Terminal"
msgid "Terminal for {0}"
msgstr "Terminal für {0}"

#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx
msgid "Terminal for {projectName}"
msgstr "Terminal für {projectName}"

#: src/mobile/TerminalAccessory.tsx
#: src/renderer/views/SettingsOverlay/parts/settingsOptions.ts
msgid "Terminal input"
Expand Down
Loading