Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions .changeset/steady-agent-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"akeru-bot": patch
---

Recover interrupted chats after server restarts and route supported providers through the unified Akeru agent controller.
24 changes: 24 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
AppState,
Keyboard,
Platform,
Pressable,
Text,
useWindowDimensions,
View,
type GestureResponderEvent,
Expand Down Expand Up @@ -116,6 +118,9 @@ export interface ThreadDetailScreenProps {
readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
readonly onRemoveDraftImage: (imageId: string) => void;
readonly onStopThread: () => void;
readonly onResumeThread: () => void;
readonly canResumeThread: boolean;
readonly resumingThread: boolean;
readonly onSendMessage: () => Promise<MessageId | null>;
readonly onReconnectEnvironment: () => void;
readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void;
Expand Down Expand Up @@ -695,6 +700,25 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
</Animated.View>
) : null}
<View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
{props.canResumeThread ? (
<View className="mx-4 mb-3 flex-row items-center gap-3 rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3">
<Text className="min-w-0 flex-1 text-sm text-foreground">
{props.selectedThread.session?.lastError ??
"The request stopped before it could finish."}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="Resume interrupted request"
className="rounded-xl bg-primary px-3 py-2 disabled:opacity-50"
disabled={props.resumingThread}
onPress={props.onResumeThread}
>
<Text className="font-semibold text-primary-foreground">
{props.resumingThread ? "Resuming…" : "Resume"}
</Text>
</Pressable>
</View>
) : null}
{props.activePendingApproval || props.activePendingUserInput ? (
<Animated.View
className="shrink-0 gap-3 px-4 pb-3"
Expand Down
19 changes: 19 additions & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ function ThreadRouteContent(
const gitActions = useSelectedThreadGitActions();
const requests = useSelectedThreadRequests();
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
const resumeThreadTurn = useAtomCommand(threadEnvironment.resumeTurn, "thread resume");
const [resumingThread, setResumingThread] = useState(false);
const navigation = useNavigation();
const params = props.route.params;
const environmentIdRaw = firstRouteParam(params.environmentId);
Expand Down Expand Up @@ -496,6 +498,14 @@ function ThreadRouteContent(
},
});
}, [interruptThreadTurn, selectedThread]);
const handleResumeThread = useCallback(() => {
if (!selectedThread || resumingThread) return;
setResumingThread(true);
void resumeThreadTurn({
environmentId: selectedThread.environmentId,
input: { threadId: selectedThread.id },
}).finally(() => setResumingThread(false));
}, [resumeThreadTurn, resumingThread, selectedThread]);

const handleOpenTerminal = useCallback(
(nextTerminalId?: string | null) => {
Expand Down Expand Up @@ -796,6 +806,15 @@ function ThreadRouteContent(
onRemoveDraftImage={composer.onRemoveDraftImage}
serverConfig={serverConfig}
onStopThread={handleStopThread}
onResumeThread={handleResumeThread}
canResumeThread={
selectedThread.session?.status === "error" &&
(selectedThread.latestTurn?.state === "error" ||
selectedThread.latestTurn?.state === "interrupted" ||
(selectedThread.latestTurn === null &&
selectedThreadDetail?.messages.at(-1)?.role === "user"))
}
resumingThread={resumingThread}
onSendMessage={composer.onSendMessage}
onReconnectEnvironment={handleReconnectEnvironment}
onUpdateThreadModelSelection={composer.onUpdateModelSelection}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const TRANSFER_BUDGET = {
export const TRANSFER_BUDGETS: Readonly<Record<string, ProviderTransferBudget>> = {
codex: TRANSFER_BUDGET,
claudeAgent: TRANSFER_BUDGET,
opencode: TRANSFER_BUDGET,
};

function totalWireBytes(run: TransferBudgetRun): number {
Expand Down
36 changes: 36 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh
import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";
import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";
import { ProjectionPendingTurnStart } from "../../persistence/Services/ProjectionTurns.ts";
import {
decodeThreadDetailPageCursor,
encodeThreadDetailPageCursor,
Expand Down Expand Up @@ -938,6 +939,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
`,
});

const listPendingTurnStartRows = SqlSchema.findAll({
Request: Schema.Void,
Result: ProjectionPendingTurnStart,
execute: () =>
sql`
SELECT
thread_id AS "threadId",
pending_message_id AS "messageId",
responding_bot_id AS "respondingBotId",
source_proposed_plan_thread_id AS "sourceProposedPlanThreadId",
source_proposed_plan_id AS "sourceProposedPlanId",
requested_at AS "requestedAt"
FROM projection_turns
WHERE turn_id IS NULL
AND state = 'pending'
AND pending_message_id IS NOT NULL
AND checkpoint_turn_count IS NULL
ORDER BY requested_at ASC, thread_id ASC
`,
});

const listActiveLatestTurnRows = SqlSchema.findAll({
Request: Schema.Void,
Result: ProjectionLatestTurnDbRowSchema,
Expand Down Expand Up @@ -3287,6 +3309,19 @@ pending_approval_requests AS (
}));
});

const listPendingTurnStarts = Effect.fn("ProjectionSnapshotQuery.listPendingTurnStarts")(
function* () {
return yield* listPendingTurnStartRows(undefined).pipe(
Effect.mapError(
toPersistenceSqlOrDecodeError(
"ProjectionSnapshotQuery.listPendingTurnStarts:query",
"ProjectionSnapshotQuery.listPendingTurnStarts:decodeRows",
),
),
);
},
);

// Contiguous turn range bounding a windowed detail read; undefined loads the
// full thread. Resolved from a window request inside the snapshot
// transaction (see getThreadDetailSnapshot).
Expand Down Expand Up @@ -3714,6 +3749,7 @@ pending_approval_requests AS (
getThreadShellById,
getThreadRuntimeContext,
getTurnStartMessage,
listPendingTurnStarts,
getThreadDetailById,
getThreadDetailSnapshot,
} satisfies ProjectionSnapshotQueryShape;
Expand Down
Loading
Loading