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
29 changes: 29 additions & 0 deletions .kilo_workflow/learnings/mobile-simulator-claim-killed-mid-boot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# mobile: a simulator claim killed mid-boot reclaims as alreadyOwned but leaves the device Shutdown (build then fails code=405)

Symptom: `pnpm dev:mobile:simulator claim` is interrupted (e.g. the invoking shell's own
command timeout kills it) after it wrote the claim lock but before the boot finished.
(A fresh claim boots before it renames, so the `Kilo E2E - <worktree>` label may or may
not have been applied yet at the moment of the kill.) A later
`pnpm dev:mobile:simulator claim` returns instantly with `alreadyOwned: true` and does
**not** boot the device. `pnpm dev:mobile:ios build <udid>` then fails:

```
An error was encountered processing the command (domain=com.apple.CoreSimulator.SimError, code=405):
Unable to lookup in current state: Shutdown
```

Cause: the reclaim path (`alreadyOwned`) returns before the boot step entirely; only a
fresh claim boots. The device is left Shutdown with the claim lock in place (the reclaim
reapplies the label if the killed attempt never got to it).

Fix (no re-claim needed, claim is idempotent):

```bash
xcrun simctl boot <udid>
xcrun simctl bootstatus <udid> -b # wait for readiness
pnpm dev:mobile:ios build <udid> # now installs
```

Prevention: run `pnpm dev:mobile:simulator claim` with a generous timeout (>= 10 min) —
booting an iPhone under parallel-workflow load exceeds 3 minutes on this machine, and the
default 2–3 minute shell timeouts kill the claim at exactly the wrong moment.
42 changes: 39 additions & 3 deletions apps/mobile/src/components/agents/session-list-body-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,51 @@ describe('selectSessionListBodyModel', () => {
});
});

it('a populated tray does not change the active-query body decision', () => {
it('a populated tray suppresses the filtered-empty body while searching', () => {
expect(
model({
hasHistoryContent: false,
hasPinnedActive: true,
hasActiveQuery: true,
isSearching: true,
}).kind
).toBe('filtered-empty');
})
).toEqual({
kind: 'render-list',
primaryAction: 'none',
showInlineError: false,
});
});

it('a populated tray does not change the filter-only body decision', () => {
expect(
model({
hasHistoryContent: false,
hasPinnedActive: true,
hasActiveQuery: true,
isSearching: false,
})
).toEqual({
kind: 'filtered-empty',
primaryAction: 'clear-filters',
showInlineError: false,
});
});

it('a populated tray does not change the query-error body decision while searching', () => {
expect(
model({
hasHistoryContent: false,
hasPinnedActive: true,
hasActiveQuery: true,
isSearching: true,
isError: true,
})
).toEqual({
kind: 'query-error-empty',
primaryAction: 'retry',
secondaryAction: 'clear-search',
showInlineError: true,
});
});
});

Expand Down
23 changes: 17 additions & 6 deletions apps/mobile/src/components/agents/session-list-body-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
* inside the SectionList body).
* - When the list body is empty (no history sections rendered):
* 1. Active search/filter query → filtered-empty OR query-error
* (when in error). The query-error variant always gets a Retry
* CTA; a secondary Clear CTA is shown for any active query
* ("Clear search" or "Clear filters"), while a no-query error
* shows only Retry.
* (when in error). EXCEPTION: a text search with a populated
* tray returns render-list — tray matches are matches, so the
* body never claims "No sessions match" beneath them. The
* query-error variant always gets a Retry CTA; a secondary
* Clear CTA is shown for any active query ("Clear search" or
* "Clear filters"), while a no-query error shows only Retry.
* 2. No query, error → retry-capable error empty state.
* 3. No query, no error → compact "No past sessions" + New coding
* task CTA.
Expand Down Expand Up @@ -100,8 +102,10 @@ export function selectSessionListBodyModel(
return { kind: 'render-list', primaryAction: 'none', showInlineError };
}

// History empty: priority is the active query branch (even when the
// tray is populated — a query should still narrow the body).
// History empty: priority is the active query branch. A text search
// with a populated tray renders the list instead (tray matches are
// matches); filter-only narrowing keeps the filtered-empty body even
// when the tray is populated (pre-existing decision).
if (hasActiveQuery) {
if (isError) {
// Query-error empty: Retry is always available. A Clear CTA is also
Expand All @@ -114,6 +118,13 @@ export function selectSessionListBodyModel(
showInlineError,
};
}
// Text search: matches in the tray are matches. Never claim "No sessions
// match" beneath a populated tray — the tray is part of the searched
// corpus. (Filter-only narrowing keeps the pre-existing behavior by
// design — the tray was always part of that corpus's decision.)
if (isSearching && hasPinnedActive) {
return { kind: 'render-list', primaryAction: 'none', showInlineError };
}
return {
kind: 'filtered-empty',
primaryAction: isSearching ? 'clear-search' : 'clear-filters',
Expand Down
146 changes: 135 additions & 11 deletions apps/mobile/src/components/agents/session-list-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,26 +32,138 @@ describe('selectPinnedActiveSessions', () => {
it('does not dedup against stored pages (returns actives regardless)', () => {
// The helper intentionally has no stored-sessions parameter. The
// exclusivity contract lives on the history side (Task 3); the pinned
// set is always driven solely by filters and the active input.
// set is driven by the search query, the platform/project filters, and the active input.
const actives: ActiveSession[] = [makeActive({ id: 'a1' }), makeActive({ id: 'a2' })];
const result = selectPinnedActiveSessions({
activeSessions: actives,
projectFilter: [],
platformFilter: [],
searchQuery: '',
});
expect(result).toEqual(actives);
});

it('has no search parameter (search is ignored by construction)', () => {
// Compile-time assertion: selectPinnedActiveSessions must not accept
// a `search` key. If one is added later, @ts-expect-error stops firing
// and typecheck fails.
selectPinnedActiveSessions({
activeSessions: [],
projectFilter: [],
platformFilter: [],
// @ts-expect-error — search must not be a param of selectPinnedActiveSessions
search: 'anything',
describe('search rules', () => {
it('empty query performs no narrowing', () => {
const a = makeActive({ id: 'a', title: 'Fix login' });
const b = makeActive({ id: 'b', title: 'Other work' });
const result = selectPinnedActiveSessions({
activeSessions: [a, b],
projectFilter: [],
platformFilter: [],
searchQuery: '',
});
expect(result).toEqual([a, b]);
});

it('whitespace-only query performs no narrowing', () => {
const a = makeActive({ id: 'a', title: 'Fix login' });
const b = makeActive({ id: 'b', title: 'Other work' });
const result = selectPinnedActiveSessions({
activeSessions: [a, b],
projectFilter: [],
platformFilter: [],
searchQuery: ' ',
});
expect(result).toEqual([a, b]);
});

it('matches a title substring mid-string', () => {
const match = makeActive({ id: 'm', title: 'Fix login bug' });
const other = makeActive({ id: 'o', title: 'Deploy release' });
const result = selectPinnedActiveSessions({
activeSessions: [match, other],
projectFilter: [],
platformFilter: [],
searchQuery: 'login',
});
expect(result.map(s => s.id)).toEqual(['m']);
});

it('title match is case-insensitive both ways', () => {
const lower = makeActive({ id: 'lower', title: 'Fix login bug' });
const upper = makeActive({ id: 'upper', title: 'FIX LOGIN BUG' });
expect(
selectPinnedActiveSessions({
activeSessions: [lower],
projectFilter: [],
platformFilter: [],
searchQuery: 'LOGIN',
}).map(s => s.id)
).toEqual(['lower']);
expect(
selectPinnedActiveSessions({
activeSessions: [upper],
projectFilter: [],
platformFilter: [],
searchQuery: 'fix',
}).map(s => s.id)
).toEqual(['upper']);
});

it('matches a session id substring, case-insensitively', () => {
const match = makeActive({ id: 'ses-ABC123', title: 'Unrelated title' });
const other = makeActive({ id: 'ses-xyz', title: 'Also unrelated' });
const result = selectPinnedActiveSessions({
activeSessions: [match, other],
projectFilter: [],
platformFilter: [],
searchQuery: 'abc123',
});
expect(result.map(s => s.id)).toEqual(['ses-ABC123']);
});

it('excludes sessions matching neither title nor id', () => {
const a = makeActive({ id: 'ses-a', title: 'Fix login' });
const b = makeActive({ id: 'ses-b', title: 'Deploy' });
const result = selectPinnedActiveSessions({
activeSessions: [a, b],
projectFilter: [],
platformFilter: [],
searchQuery: 'zzz',
});
expect(result).toEqual([]);
});

it('applies the text query in conjunction with platform and project filters', () => {
const pass = makeActive({
id: 'pass',
title: 'Fix login bug',
gitUrl: 'git@github.com:org/repo.git',
createdOnPlatform: 'cli',
});
const wrongPlatform = makeActive({
id: 'wrongPlatform',
title: 'Fix login bug',
gitUrl: 'git@github.com:org/repo.git',
createdOnPlatform: 'slack',
});
const wrongProject = makeActive({
id: 'wrongProject',
title: 'Fix login bug',
gitUrl: 'git@github.com:org/other.git',
createdOnPlatform: 'cli',
});
const result = selectPinnedActiveSessions({
activeSessions: [pass, wrongPlatform, wrongProject],
projectFilter: ['git@github.com:org/repo.git'],
platformFilter: ['cli'],
searchQuery: 'login',
});
expect(result.map(s => s.id)).toEqual(['pass']);
});

it('treats LIKE metacharacters literally (parity with server position() search)', () => {
const percent = makeActive({ id: 'pct', title: '100% done' });
const noPercent = makeActive({ id: 'nop', title: '1000 done' });
expect(
selectPinnedActiveSessions({
activeSessions: [percent, noPercent],
projectFilter: [],
platformFilter: [],
searchQuery: '100%',
}).map(s => s.id)
).toEqual(['pct']);
});
});

Expand All @@ -63,6 +175,7 @@ describe('selectPinnedActiveSessions', () => {
activeSessions: [withUrl, withoutUrl],
projectFilter: ['git@github.com:org/repo.git'],
platformFilter: [],
searchQuery: '',
});
expect(result.map(s => s.id)).toEqual(['with']);
});
Expand All @@ -74,6 +187,7 @@ describe('selectPinnedActiveSessions', () => {
activeSessions: [match, other],
projectFilter: ['git@github.com:org/repo.git'],
platformFilter: [],
searchQuery: '',
});
expect(result.map(s => s.id)).toEqual(['match']);
});
Expand All @@ -86,6 +200,7 @@ describe('selectPinnedActiveSessions', () => {
activeSessions: [a, b, c],
projectFilter: [],
platformFilter: [],
searchQuery: '',
});
expect(result.map(s => s.id)).toEqual(['a', 'b', 'c']);
});
Expand All @@ -99,6 +214,7 @@ describe('selectPinnedActiveSessions', () => {
activeSessions: [cli, slack],
projectFilter: [],
platformFilter: ['cli'],
searchQuery: '',
});
expect(result.map(s => s.id)).toEqual(['cli']);
});
Expand All @@ -110,6 +226,7 @@ describe('selectPinnedActiveSessions', () => {
activeSessions: [web, ca],
projectFilter: [],
platformFilter: ['cloud-agent'],
searchQuery: '',
});
expect(result.map(s => s.id).toSorted()).toEqual(['ca', 'web']);
});
Expand All @@ -122,6 +239,7 @@ describe('selectPinnedActiveSessions', () => {
activeSessions: [vscode, am, slack],
projectFilter: [],
platformFilter: ['extension'],
searchQuery: '',
});
expect(result.map(s => s.id).toSorted()).toEqual(['am', 'vscode']);
});
Expand All @@ -132,6 +250,7 @@ describe('selectPinnedActiveSessions', () => {
activeSessions: [weird],
projectFilter: [],
platformFilter: ['other'],
searchQuery: '',
});
expect(result.map(s => s.id)).toEqual(['weird']);
});
Expand All @@ -142,6 +261,7 @@ describe('selectPinnedActiveSessions', () => {
activeSessions: [cli],
projectFilter: [],
platformFilter: ['other'],
searchQuery: '',
});
expect(result).toEqual([]);
});
Expand All @@ -154,6 +274,7 @@ describe('selectPinnedActiveSessions', () => {
activeSessions: [cli, weird, vscode],
projectFilter: [],
platformFilter: ['cli', 'other'],
searchQuery: '',
});
expect(result.map(s => s.id).toSorted()).toEqual(['cli', 'weird']);
});
Expand All @@ -165,6 +286,7 @@ describe('selectPinnedActiveSessions', () => {
activeSessions: [unknown, cli],
projectFilter: [],
platformFilter: ['cli'],
searchQuery: '',
});
expect(result.map(s => s.id)).toEqual(['cli']);
});
Expand All @@ -176,6 +298,7 @@ describe('selectPinnedActiveSessions', () => {
activeSessions: [unknown, cli],
projectFilter: [],
platformFilter: [],
searchQuery: '',
});
expect(result.map(s => s.id).toSorted()).toEqual(['cli', 'unknown']);
});
Expand All @@ -201,6 +324,7 @@ describe('selectPinnedActiveSessions', () => {
activeSessions: [pass, wrongProject, wrongPlatform],
projectFilter: ['git@github.com:org/repo.git'],
platformFilter: ['cli'],
searchQuery: '',
});
expect(result.map(s => s.id)).toEqual(['pass']);
});
Expand Down
Loading
Loading