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
31 changes: 31 additions & 0 deletions .kilo_workflow/learnings/maestro-remote-exit-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Driving remote `/exit` on iOS via Maestro (remote-cli E2E)

Learned 2026-07-28 during plan 1.7 (Defect A) round r3 verification.

1. **`hideKeyboard` fails in the chat composer.** Maestro reports "Couldn't hide
the keyboard... app uses a custom input". Omit `hideKeyboard`; the "Send
message" button sits above the keyboard and the tap lands fine (contrast
with `maestro-tap-swallowed-by-ios-keyboard.md`, which covers taps *under*
the keyboard window).
2. **Exact-typed `/exit` does not exit immediately.** The app shows a native
confirmation dialog "Exit session?" with buttons "Exit session" and
"Keep session running" ("This stops the running session but keeps its
history."). A flow must tap "Exit session" before asserting the
"Session exited" success toast.
3. **`inputText: "/exit"` lands as `/exit ` (trailing space)** — the slash
suggestion UI commits a trailing space. Harmless: the composer parser
trims input before matching, so it still routes to `exit-session`.
4. **Selecting a remote CLI instance collapses the new-session form** to just
"Run on" + "Start session" (`RemoteSpawnComposer`): model / mode / repo /
prompt are hidden by design, so no model selection or prompt typing step
exists for remote CLI spawns. The session composer later shows the CLI's
own model (observed "Auto Efficient").
5. **Instance row label format**: the Run-on sheet rows are
`<Host> on <worktree>` (e.g. `Igor-MacBook.local on remote-cli-69f6`)
while the collapsed form value is `Run on: <Host> · <worktree>` (`·`
separator). Maestro `text:` matching is full-string — match the exact
label including the separator.
6. After a successful exit the app routes to the Agents tab; the exited
session stays listed ("... from CLI" row). Assert "New session" visible +
`assertNotVisible` "Session owner changed" and "Try again" to prove the
Defect-A regression is gone.
2 changes: 1 addition & 1 deletion apps/mobile/src/app/(app)/agent-chat/instance-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ export default function InstancePickerScreen() {
icon={Server}
placement="top"
title="No CLI instances connected"
description="Run `kilo remote` in a project on your computer to connect one."
description="Run `kilo remote` in a project on your computer, or update Kilo CLI if one is already running."
action={
<Button
variant="outline"
Expand Down
165 changes: 165 additions & 0 deletions services/session-ingest/src/dos/UserConnectionDO.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3045,6 +3045,171 @@ describe('UserConnectionDO', () => {

expect(parseSent(webWs)).toEqual({ type: 'response', id: 'cmd-1', result });
});

it('resolves exit_cli successfully when heartbeat drops the session', () => {
const { doInstance, mockCtx } = setup();
const cliWs = addCliSocket(mockCtx, 'cli-1');
const webWs = addWebSocket(mockCtx, 'web-1');

sendHeartbeat(doInstance, cliWs, [makeSession('s1')]);
cliWs.send.mockClear();
webWs.send.mockClear();
sendCommand(doInstance, webWs, {
id: 'cmd-1',
command: 'exit_cli',
sessionId: 's1',
connectionId: 'cli-1',
data: { protocolVersion: 1 },
});
const correlationId = getCorrelationId(cliWs);
webWs.send.mockClear();

// Exit's own effect: CLI heartbeat no longer lists the session.
sendHeartbeat(doInstance, cliWs, []);

expect(allSent(webWs).find(m => m.type === 'response' && m.id === 'cmd-1')).toEqual({
type: 'response',
id: 'cmd-1',
result: {},
});
expect(
allSent(webWs).some(
m =>
m.type === 'response' &&
m.id === 'cmd-1' &&
isRecord(m.error) &&
m.error.code === 'SESSION_OWNER_CHANGED'
)
).toBe(false);

// CLI's late ACK must not produce a second cmd-1 response.
const responsesBeforeLateAck = allSent(webWs).filter(
m => m.type === 'response' && m.id === 'cmd-1'
).length;
sendCliResponse(doInstance, cliWs, { id: correlationId, result: {} });
expect(allSent(webWs).filter(m => m.type === 'response' && m.id === 'cmd-1')).toHaveLength(
responsesBeforeLateAck
);
});

it('resolves exit_cli successfully when the owning socket closes', async () => {
const { doInstance, mockCtx } = setup();
const cliWs = addCliSocket(mockCtx, 'cli-1');
const webWs = addWebSocket(mockCtx, 'web-1');

sendHeartbeat(doInstance, cliWs, [makeSession('s1')]);
sendCommand(doInstance, webWs, {
id: 'cmd-1',
command: 'exit_cli',
sessionId: 's1',
connectionId: 'cli-1',
data: { protocolVersion: 1 },
});
webWs.send.mockClear();

mockCtx.removeSocket(cliWs);
await disconnectCli(doInstance, cliWs);

expect(parseSent(webWs)).toEqual({
type: 'response',
id: 'cmd-1',
result: {},
});
});

it.each(['list_models', 'send_message'] as const)(
'still fails %s with SESSION_OWNER_CHANGED when heartbeat drops the session',
command => {
const { doInstance, mockCtx } = setup();
const cliWs = addCliSocket(mockCtx, 'cli-1');
const webWs = addWebSocket(mockCtx, 'web-1');

sendHeartbeat(doInstance, cliWs, [makeSession('s1')]);
cliWs.send.mockClear();
webWs.send.mockClear();
sendCommand(doInstance, webWs, {
id: 'cmd-1',
command,
sessionId: 's1',
connectionId: 'cli-1',
});
webWs.send.mockClear();

sendHeartbeat(doInstance, cliWs, []);

expect(allSent(webWs).find(m => m.type === 'response' && m.id === 'cmd-1')).toEqual({
type: 'response',
id: 'cmd-1',
error: {
source: 'relay',
code: 'SESSION_OWNER_CHANGED',
message: 'Session owner changed',
},
});
}
);

it('still fails exit_cli with SESSION_OWNER_CHANGED on genuine takeover', () => {
const { doInstance, mockCtx } = setup();
const firstOwner = addCliSocket(mockCtx, 'cli-1');
const nextOwner = addCliSocket(mockCtx, 'cli-2');
const webWs = addWebSocket(mockCtx, 'web-1');

sendHeartbeat(doInstance, firstOwner, [makeSession('s1')]);
sendHeartbeat(doInstance, nextOwner, []);
firstOwner.send.mockClear();
webWs.send.mockClear();
sendCommand(doInstance, webWs, {
id: 'cmd-1',
command: 'exit_cli',
sessionId: 's1',
connectionId: 'cli-1',
data: { protocolVersion: 1 },
});
webWs.send.mockClear();

sendHeartbeat(doInstance, nextOwner, [makeSession('s1')]);

expect(allSent(webWs).find(m => m.type === 'response' && m.id === 'cmd-1')).toEqual({
type: 'response',
id: 'cmd-1',
error: {
source: 'relay',
code: 'SESSION_OWNER_CHANGED',
message: 'Session owner changed',
},
});
});

it('still fails exit_cli with SESSION_OWNER_CHANGED when socket is replaced by reconnect', () => {
const { doInstance, mockCtx } = setup();
const firstCli = connectCliSocket(doInstance, 'cli-1');
const webWs = addWebSocket(mockCtx, 'web-1');

sendHeartbeat(doInstance, firstCli, [makeSession('s1')]);
firstCli.send.mockClear();
sendCommand(doInstance, webWs, {
id: 'cmd-1',
command: 'exit_cli',
sessionId: 's1',
connectionId: 'cli-1',
data: { protocolVersion: 1 },
});
webWs.send.mockClear();

connectCliSocket(doInstance, 'cli-1');

expect(firstCli.close).toHaveBeenCalledWith(1000, 'replaced by reconnect');
expect(parseSent(webWs)).toEqual({
type: 'response',
id: 'cmd-1',
error: {
source: 'relay',
code: 'SESSION_OWNER_CHANGED',
message: 'Session owner changed',
},
});
});
});

// -------------------------------------------------------------------------
Expand Down
45 changes: 29 additions & 16 deletions services/session-ingest/src/dos/UserConnectionDO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,7 +860,7 @@ export class UserConnectionDO extends DurableObject<Env> {
});

// Fail pending commands that targeted this specific socket
this.failPendingCommandsForSocket(disconnectedWs);
this.failPendingCommandsForSocket(disconnectedWs, !replaced);

if (replaced) {
console.log('Stale CLI socket closed (already replaced)', { connectionId });
Expand Down Expand Up @@ -1083,7 +1083,7 @@ export class UserConnectionDO extends DurableObject<Env> {
const att = ws.deserializeAttachment() as WSAttachment | null;
if (att?.role === 'cli' && att.connectionId === connectionId) {
console.log('Closing stale CLI socket for reconnect', { connectionId });
this.failPendingCommandsForSocket(ws);
this.failPendingCommandsForSocket(ws, false);
// Preserve session ownership — the reconnecting CLI still owns these sessions
ws.close(1000, 'replaced by reconnect');
return true;
Expand Down Expand Up @@ -1132,16 +1132,25 @@ export class UserConnectionDO extends DurableObject<Env> {
return undefined;
}

private failPendingCommandsForSocket(targetWs: WebSocket): void {
private failPendingCommandsForSocket(targetWs: WebSocket, cliGone: boolean): void {
for (const [id, entry] of this.pendingCommands) {
if (entry.targetCliWs === targetWs) {
this.sendToWeb(entry.ws, {
type: 'response',
id: entry.originalId,
error: entry.expectedOwnerConnectionId ? SESSION_OWNER_CHANGED_ERROR : 'CLI disconnected',
});
this.pendingCommands.delete(id);
}
if (entry.targetCliWs !== targetWs) continue;
// The owning CLI is really gone, so a forwarded `exit_cli` got what it
// asked for: this session is no longer owned by anyone.
const exited = cliGone && entry.command === 'exit_cli';
this.sendToWeb(
entry.ws,
exited
? { type: 'response', id: entry.originalId, result: {} }
: {
type: 'response',
id: entry.originalId,
error: entry.expectedOwnerConnectionId
? SESSION_OWNER_CHANGED_ERROR
: 'CLI disconnected',
}
);
this.pendingCommands.delete(id);
}
}

Expand All @@ -1154,11 +1163,15 @@ export class UserConnectionDO extends DurableObject<Env> {
continue;
}
this.pendingCommands.delete(id);
this.sendToWeb(entry.ws, {
type: 'response',
id: entry.originalId,
error: SESSION_OWNER_CHANGED_ERROR,
});
// `exit_cli` asked for exactly this: the session is no longer owned.
// Ownership moving to another CLI is a genuine owner change and still fails.
const exited = entry.command === 'exit_cli' && nextOwnerConnectionId === undefined;
this.sendToWeb(
entry.ws,
exited
? { type: 'response', id: entry.originalId, result: {} }
: { type: 'response', id: entry.originalId, error: SESSION_OWNER_CHANGED_ERROR }
);
}
}

Expand Down
Loading