From c88ef71736777fd86e6ce2cac7a7d8df3dc07fcb Mon Sep 17 00:00:00 2001
From: Remon Oldenbeuving
Date: Tue, 31 Mar 2026 16:17:25 +0200
Subject: [PATCH 1/2] fix(kiloclaw): propagate controller_route_unavailable
through DO RPC boundary
The GatewayControllerError.code property was lost when crossing the DO
RPC serialization boundary, causing startKiloCliRun to return a generic
500 instead of the controller_route_unavailable code the client needs
to show the redeploy UI.
Follow the established pattern: catch isErrorUnknownRoute in the DO and
return null, then handle null in the platform route to emit the proper
error code in the HTTP response body.
---
.../kiloclaw-instance/kilo-cli-run.ts | 25 +--
kiloclaw/src/routes/platform.ts | 11 +-
.../migrations/0064_add_kiloclaw_cli_runs.sql | 2 +-
.../claw/components/StartKiloCliRunDialog.tsx | 158 +++++++++++++-----
src/routers/kiloclaw-router.ts | 18 +-
5 files changed, 154 insertions(+), 60 deletions(-)
diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance/kilo-cli-run.ts b/kiloclaw/src/durable-objects/kiloclaw-instance/kilo-cli-run.ts
index 85c5d343a7..43bba17bca 100644
--- a/kiloclaw/src/durable-objects/kiloclaw-instance/kilo-cli-run.ts
+++ b/kiloclaw/src/durable-objects/kiloclaw-instance/kilo-cli-run.ts
@@ -4,7 +4,7 @@ import {
KiloCliRunStatusResponseSchema,
GatewayCommandResponseSchema,
} from '../gateway-controller-types';
-import { callGatewayController } from './gateway';
+import { callGatewayController, isErrorUnknownRoute } from './gateway';
import type { InstanceMutableState } from './types';
type KiloCliRunStartResponse = {
@@ -29,19 +29,24 @@ export async function startKiloCliRun(
state: InstanceMutableState,
env: KiloClawEnv,
prompt: string
-): Promise {
+): Promise {
if (state.status !== 'running' || !state.flyMachineId) {
throw Object.assign(new Error('Instance is not running'), { status: 409 });
}
- return callGatewayController(
- state,
- env,
- '/_kilo/cli-run/start',
- 'POST',
- KiloCliRunStartResponseSchema,
- { prompt }
- );
+ try {
+ return await callGatewayController(
+ state,
+ env,
+ '/_kilo/cli-run/start',
+ 'POST',
+ KiloCliRunStartResponseSchema,
+ { prompt }
+ );
+ } catch (error) {
+ if (isErrorUnknownRoute(error)) return null;
+ throw error;
+ }
}
/**
diff --git a/kiloclaw/src/routes/platform.ts b/kiloclaw/src/routes/platform.ts
index 9dd1a62f96..c699d53aa0 100644
--- a/kiloclaw/src/routes/platform.ts
+++ b/kiloclaw/src/routes/platform.ts
@@ -982,10 +982,17 @@ platform.post('/kilo-cli-run/start', async c => {
stub => stub.startKiloCliRun(result.data.prompt),
'startKiloCliRun'
);
+ if (!response) {
+ return jsonError(
+ 'Kilo CLI agent not available (controller too old)',
+ 404,
+ 'controller_route_unavailable'
+ );
+ }
return c.json(response, 200);
} catch (err) {
- const { message, status } = sanitizeError(err, 'kilo-cli-run start');
- return jsonError(message, status);
+ const { message, status, code } = sanitizeOpenclawConfigError(err, 'kilo-cli-run start');
+ return jsonError(message, status, code);
}
});
diff --git a/packages/db/src/migrations/0064_add_kiloclaw_cli_runs.sql b/packages/db/src/migrations/0064_add_kiloclaw_cli_runs.sql
index cc18c6f420..5c1a216a94 100644
--- a/packages/db/src/migrations/0064_add_kiloclaw_cli_runs.sql
+++ b/packages/db/src/migrations/0064_add_kiloclaw_cli_runs.sql
@@ -11,4 +11,4 @@ CREATE TABLE "kiloclaw_cli_runs" (
--> statement-breakpoint
ALTER TABLE "kiloclaw_cli_runs" ADD CONSTRAINT "kiloclaw_cli_runs_user_id_kilocode_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "IDX_kiloclaw_cli_runs_user_id" ON "kiloclaw_cli_runs" USING btree ("user_id");--> statement-breakpoint
-CREATE INDEX "IDX_kiloclaw_cli_runs_started_at" ON "kiloclaw_cli_runs" USING btree ("started_at");
\ No newline at end of file
+CREATE INDEX "IDX_kiloclaw_cli_runs_started_at" ON "kiloclaw_cli_runs" USING btree ("started_at");
diff --git a/src/app/(app)/claw/components/StartKiloCliRunDialog.tsx b/src/app/(app)/claw/components/StartKiloCliRunDialog.tsx
index c2bef8d98b..3887328a17 100644
--- a/src/app/(app)/claw/components/StartKiloCliRunDialog.tsx
+++ b/src/app/(app)/claw/components/StartKiloCliRunDialog.tsx
@@ -2,7 +2,8 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
-import { Loader2, Terminal } from 'lucide-react';
+import { AlertTriangle, Loader2, RotateCw, Terminal } from 'lucide-react';
+import { toast } from 'sonner';
import {
Dialog,
DialogContent,
@@ -14,6 +15,19 @@ import {
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { useKiloClawMutations } from '@/hooks/useKiloClaw';
+import { AnimatedDots } from './AnimatedDots';
+
+function isNeedsRedeployError(error: unknown): boolean {
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ 'data' in error &&
+ typeof (error as { data?: unknown }).data === 'object' &&
+ (error as { data?: { upstreamCode?: unknown } }).data !== null &&
+ (error as { data: { upstreamCode?: unknown } }).data.upstreamCode ===
+ 'controller_route_unavailable'
+ );
+}
export function StartKiloCliRunDialog({
open,
@@ -26,6 +40,9 @@ export function StartKiloCliRunDialog({
const [prompt, setPrompt] = useState('');
const mutations = useKiloClawMutations();
const startMutation = mutations.startKiloCliRun;
+ const redeployMutation = mutations.restartMachine;
+
+ const needsRedeploy = startMutation.isError && isNeedsRedeployError(startMutation.error);
const handleStart = () => {
const trimmed = prompt.trim();
@@ -41,6 +58,19 @@ export function StartKiloCliRunDialog({
);
};
+ const handleRedeploy = () => {
+ redeployMutation.mutate(
+ { imageTag: 'latest' },
+ {
+ onSuccess: () => {
+ toast.success('Upgrading to latest version');
+ onOpenChange(false);
+ },
+ onError: err => toast.error(err.message, { duration: 10000 }),
+ }
+ );
+ };
+
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setPrompt('');
@@ -58,55 +88,91 @@ export function StartKiloCliRunDialog({
Recover with Kilo CLI Agent
- If your KiloClaw instance is stuck or failing, the Kilo CLI agent can help diagnose and
- fix the problem. Describe the issue below and the agent will work autonomously to
- resolve it.
+ {needsRedeploy
+ ? 'Your instance needs to be redeployed before the recovery agent can run.'
+ : 'If your KiloClaw instance is stuck or failing, the Kilo CLI agent can help diagnose and fix the problem. Describe the issue below and the agent will work autonomously to resolve it.'}
-
-
+ {needsRedeploy ? (
+ <>
+
+
+
+ Your KiloClaw instance is running an older version that doesn't support the
+ recovery agent. Upgrade to the latest version to use this feature.
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
-
-
-
-
+
+
+
+
+ >
+ )}
);
diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts
index 53980965d7..848f7cbc55 100644
--- a/src/routers/kiloclaw-router.ts
+++ b/src/routers/kiloclaw-router.ts
@@ -1000,7 +1000,23 @@ export const kiloclawRouter = createTRPCRouter({
.input(z.object({ prompt: z.string().min(1).max(10_000) }))
.mutation(async ({ ctx, input }) => {
const client = new KiloClawInternalClient();
- const result = await client.startKiloCliRun(ctx.user.id, input.prompt);
+
+ let result;
+ try {
+ result = await client.startKiloCliRun(ctx.user.id, input.prompt);
+ } catch (err) {
+ if (err instanceof KiloClawApiError) {
+ const { code } = getKiloClawApiErrorPayload(err);
+ if (code === 'controller_route_unavailable') {
+ throw new TRPCError({
+ code: 'PRECONDITION_FAILED',
+ message: 'Instance needs redeploy to support recovery',
+ cause: new UpstreamApiError('controller_route_unavailable'),
+ });
+ }
+ }
+ throw err;
+ }
// Persist the run in the database and return its ID
const [row] = await db
From cba58b19bcbb008a6e09b13981c42ebb634b192b Mon Sep 17 00:00:00 2001
From: Remon Oldenbeuving
Date: Wed, 1 Apr 2026 12:03:29 +0200
Subject: [PATCH 2/2] fix(kiloclaw): keep recovery dialog open during redeploy
and block until machine ready
After dispatching a redeploy, the dialog now stays open and transitions
to a waiting state with a restart banner. The prompt textarea and Run
Recovery button are disabled until the machine status polls back as
running. Stale needs-redeploy errors are cleared via useEffect when
machineStatus changes, so reopening the dialog after a redeploy shows
the correct state.
---
.../claw/components/InstanceControls.tsx | 6 ++-
.../claw/components/StartKiloCliRunDialog.tsx | 41 ++++++++++++++++---
2 files changed, 40 insertions(+), 7 deletions(-)
diff --git a/src/app/(app)/claw/components/InstanceControls.tsx b/src/app/(app)/claw/components/InstanceControls.tsx
index 2ca4780834..c8373e2495 100644
--- a/src/app/(app)/claw/components/InstanceControls.tsx
+++ b/src/app/(app)/claw/components/InstanceControls.tsx
@@ -400,7 +400,11 @@ export function InstanceControls({
onOpenChange={setDoctorOpen}
mutation={mutations.runDoctor}
/>
-
+
);
}
diff --git a/src/app/(app)/claw/components/StartKiloCliRunDialog.tsx b/src/app/(app)/claw/components/StartKiloCliRunDialog.tsx
index 3887328a17..2254498e77 100644
--- a/src/app/(app)/claw/components/StartKiloCliRunDialog.tsx
+++ b/src/app/(app)/claw/components/StartKiloCliRunDialog.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Loader2, RotateCw, Terminal } from 'lucide-react';
import { toast } from 'sonner';
@@ -15,6 +15,7 @@ import {
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { useKiloClawMutations } from '@/hooks/useKiloClaw';
+import type { PlatformStatusResponse } from '@/lib/kiloclaw/types';
import { AnimatedDots } from './AnimatedDots';
function isNeedsRedeployError(error: unknown): boolean {
@@ -32,9 +33,11 @@ function isNeedsRedeployError(error: unknown): boolean {
export function StartKiloCliRunDialog({
open,
onOpenChange,
+ machineStatus,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
+ machineStatus: PlatformStatusResponse['status'];
}) {
const router = useRouter();
const [prompt, setPrompt] = useState('');
@@ -43,6 +46,16 @@ export function StartKiloCliRunDialog({
const redeployMutation = mutations.restartMachine;
const needsRedeploy = startMutation.isError && isNeedsRedeployError(startMutation.error);
+ const machineReady = machineStatus === 'running';
+
+ // Clear stale "needs redeploy" error when the machine status changes away
+ // from running (e.g. restarting after a redeploy was dispatched). This
+ // ensures reopening the dialog shows the prompt form, not the old error.
+ useEffect(() => {
+ if (needsRedeploy && machineStatus !== 'running') {
+ startMutation.reset();
+ }
+ }, [needsRedeploy, machineStatus, startMutation]);
const handleStart = () => {
const trimmed = prompt.trim();
@@ -63,8 +76,7 @@ export function StartKiloCliRunDialog({
{ imageTag: 'latest' },
{
onSuccess: () => {
- toast.success('Upgrading to latest version');
- onOpenChange(false);
+ startMutation.reset();
},
onError: err => toast.error(err.message, { duration: 10000 }),
}
@@ -72,6 +84,7 @@ export function StartKiloCliRunDialog({
};
const handleOpenChange = (nextOpen: boolean) => {
+ if (!nextOpen && redeployMutation.isPending) return;
if (!nextOpen) {
setPrompt('');
startMutation.reset();
@@ -90,7 +103,9 @@ export function StartKiloCliRunDialog({
{needsRedeploy
? 'Your instance needs to be redeployed before the recovery agent can run.'
- : 'If your KiloClaw instance is stuck or failing, the Kilo CLI agent can help diagnose and fix the problem. Describe the issue below and the agent will work autonomously to resolve it.'}
+ : !machineReady
+ ? 'Waiting for your instance to come back online before the recovery agent can run.'
+ : 'If your KiloClaw instance is stuck or failing, the Kilo CLI agent can help diagnose and fix the problem. Describe the issue below and the agent will work autonomously to resolve it.'}
@@ -104,7 +119,11 @@ export function StartKiloCliRunDialog({