Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d10f888
docs(lastcode): plan durable worktree cleanup
lastobelus Aug 23, 2026
f6c19b4
feat(lastcode): make worktree cleanup durable
lastobelus Aug 24, 2026
b38b11c
fix(lastcode): fence durable worktree cleanup
lastobelus Aug 24, 2026
6f3ce0a
fix(lastcode): keep cleanup tombstones inert
lastobelus Aug 24, 2026
83783fa
fix(lastcode): harden cleanup version boundaries
lastobelus Aug 24, 2026
d28d55f
fix(web): keep cleanup hover details readable
lastobelus Aug 24, 2026
5b6f29f
fix(lastcode): complete cleanup recovery paths
lastobelus Aug 24, 2026
2cd0853
fix(lastcode): preserve cleanup recovery
lastobelus Aug 24, 2026
b1577c4
fix(lastcode): retain cleanup completion retries
lastobelus Aug 24, 2026
16cdb31
fix(lastcode): serialize durable cleanup safely
lastobelus Aug 24, 2026
bf9061c
fix(web): preserve cleanup recovery paths
lastobelus Aug 24, 2026
795bd93
fix(mobile): surface cleanup tombstones in v2
lastobelus Aug 24, 2026
eece716
fix(server): retire idle cleanup workers
lastobelus Aug 24, 2026
3795fc4
fix(shared): release retired worker resources
lastobelus Aug 24, 2026
15cb754
fix(web): clean up archived thread worktrees
lastobelus Aug 24, 2026
0e9934b
fix(server): preserve active project workspaces
lastobelus Aug 24, 2026
0956a4c
fix(web): include archived worktree owners
lastobelus Aug 24, 2026
75884be
fix(server): resolve cleanup ownership paths
lastobelus Aug 24, 2026
e8c8d33
fix(sync): negotiate cleanup tombstones
lastobelus Aug 24, 2026
937f2b3
fix(web): use tooltip width for cleanup paths
lastobelus Aug 24, 2026
a2c2cde
Revert "fix(sync): negotiate cleanup tombstones"
lastobelus Aug 24, 2026
2803d89
fix(server): close cleanup concurrency gaps
lastobelus Aug 24, 2026
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
11 changes: 8 additions & 3 deletions apps/mobile/src/features/threads/thread-list-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {

const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } =
props;
const cleanupPending = thread.worktreeCleanup != null;
const runningAction = thread.actionResume?.outcome === "running" ? thread.actionResume : null;
const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false });
const status = resolveThreadStatus(thread);
Expand Down Expand Up @@ -598,7 +599,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
accessibilityHint="Swipe left for archive and delete actions"
accessibilityLabel={threadAccessibilityLabel}
accessibilityRole="button"
accessibilityState={{ disabled: cleanupPending }}
className="bg-screen"
disabled={cleanupPending}
onPress={() => {
close();
onSelectThread(thread);
Expand Down Expand Up @@ -651,7 +654,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
accessibilityHint="Opens the thread"
accessibilityLabel={threadAccessibilityLabel}
accessibilityRole="button"
accessibilityState={{ selected }}
accessibilityState={{ disabled: cleanupPending, selected }}
disabled={cleanupPending}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
onPress={() => {
Expand Down Expand Up @@ -711,6 +715,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
return (
<ThreadSwipeable
backgroundColor={backgroundColor}
enabled={!cleanupPending}
containerStyle={
compact ? undefined : { borderRadius: SIDEBAR_ROW_RADIUS, overflow: "hidden" }
}
Expand All @@ -734,9 +739,9 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
// ControlPillMenu injects onLongPress into the row and anchors the
// token-styled dropdown to it; taps and swipes are untouched.
<ControlPillMenu
actions={menuActions}
actions={cleanupPending ? [] : menuActions}
onPressAction={handleMenuAction}
shouldOpenOnLongPress
shouldOpenOnLongPress={!cleanupPending}
Comment thread
lastobelus marked this conversation as resolved.
Resolved
>
{rowContent(close)}
</ControlPillMenu>
Expand Down
30 changes: 18 additions & 12 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const snoozedRow = props.snoozed === true;
const pinnedRow = props.pinned === true;
const runningAction = thread.actionResume?.outcome === "running" ? thread.actionResume : null;
const cleanupPending = thread.worktreeCleanup != null;
const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false });

const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);
Expand Down Expand Up @@ -848,7 +849,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
accessibilityHint={swipeAccessibilityHint}
accessibilityLabel={thread.title}
accessibilityRole="button"
accessibilityState={{ selected }}
accessibilityState={{ disabled: cleanupPending, selected }}
disabled={cleanupPending}
onPress={() => {
close();
onSelectThread(thread);
Expand Down Expand Up @@ -888,7 +890,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
accessibilityHint={swipeAccessibilityHint}
accessibilityLabel={thread.title}
accessibilityRole="button"
accessibilityState={{ selected }}
accessibilityState={{ disabled: cleanupPending, selected }}
disabled={cleanupPending}
className={sidebarPane ? undefined : "bg-screen"}
onPress={() => {
close();
Expand Down Expand Up @@ -971,6 +974,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
sidebarPane ? { borderRadius: SIDEBAR_V2_ROW_RADIUS, overflow: "hidden" } : undefined
}
enableTrackpadSwipe
enabled={!cleanupPending}
// Full swipe commits the advertised lifecycle action (Settle /
// Un-settle), never the secondary snooze action.
fullSwipeAction="primary"
Expand All @@ -987,18 +991,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
{(close) => (
<ControlPillMenu
actions={
snoozedRow
? snoozedMenuActions
: !props.settlementSupported
? legacyMenuActions
: canUnsettle
? slimMenuActions
: swipeActions.secondary === "snooze"
? snoozableCardMenuActions
: cardMenuActions
cleanupPending
? []
Comment thread
lastobelus marked this conversation as resolved.
Resolved
: snoozedRow
? snoozedMenuActions
: !props.settlementSupported
? legacyMenuActions
: canUnsettle
? slimMenuActions
: swipeActions.secondary === "snooze"
? snoozableCardMenuActions
: cardMenuActions
}
onPressAction={handleMenuAction}
shouldOpenOnLongPress
shouldOpenOnLongPress={!cleanupPending}
>
{rowContent(close)}
</ControlPillMenu>
Expand Down
31 changes: 31 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,37 @@ describe("resolveThreadListV2Enabled", () => {
});

describe("resolveThreadListV2Status", () => {
it("shows durable cleanup before agent status", () => {
const deleting = makeThread({
id: ThreadId.make("cleanup"),
title: "Cleanup",
worktreeCleanup: {
status: "deleting",
repositoryRoot: "/repo",
worktreePath: "/repo-worktrees/cleanup",
startedAt: NOW,
},
});
expect(resolveThreadStatus(deleting)).toMatchObject({
kind: "cleanup-deleting",
label: "Deleting",
pulse: false,
});
expect(
resolveThreadStatus({
...deleting,
worktreeCleanup: {
status: "failed",
repositoryRoot: "/repo",
worktreePath: "/repo-worktrees/cleanup",
startedAt: NOW,
failedAt: NOW,
error: "permission denied",
},
}),
).toMatchObject({ kind: "cleanup-failed", label: "Cleanup failed" });
});

it("prioritizes approval over a running session", () => {
const thread = makeThread({
id: ThreadId.make("t"),
Expand Down
39 changes: 39 additions & 0 deletions apps/mobile/src/features/threads/threadPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export type ThreadStatusKind =
| "waiting"
| "connecting"
| "error"
| "cleanup-deleting"
| "cleanup-queued"
| "cleanup-failed"
| "plan-ready";

export interface ThreadStatusPresentation extends StatusTone {
Expand Down Expand Up @@ -50,6 +53,42 @@ function isLatestTurnSettled(
export function resolveThreadStatus(
thread: EnvironmentThreadShell,
): ThreadStatusPresentation | null {
if (thread.worktreeCleanup?.status === "failed") {
return {
kind: "cleanup-failed",
label: "Cleanup failed",
pillClassName: "bg-rose-500/12 dark:bg-rose-500/16",
textClassName: "text-rose-700 dark:text-rose-300",
iconColor: "#ff453a",
iconBackground: "rgba(255,69,58,0.22)",
pulse: false,
};
}

if (thread.worktreeCleanup?.status === "queued") {
return {
kind: "cleanup-queued",
label: "Deleting (Queued)",
pillClassName: "bg-orange-500/12 dark:bg-orange-500/16",
textClassName: "text-orange-700 dark:text-orange-300",
iconColor: "#ff9f0a",
iconBackground: "rgba(255,159,10,0.22)",
pulse: false,
};
}

if (thread.worktreeCleanup?.status === "deleting") {
return {
kind: "cleanup-deleting",
label: "Deleting",
pillClassName: "bg-orange-500/12 dark:bg-orange-500/16",
textClassName: "text-orange-700 dark:text-orange-300",
iconColor: "#ff9f0a",
iconBackground: "rgba(255,159,10,0.22)",
pulse: false,
};
}

if (thread.hasPendingApprovals) {
return {
kind: "pending-approval",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
expect(second.capabilities.pullRequests).toBe(true);
expect(second.capabilities.threadTitleRegeneration).toBe(true);
expect(second.capabilities.threadAnnotations).toBe(true);
expect(second.capabilities.threadWorktreeCleanup).toBe(true);
expect(second.capabilities.agentActivityPublishing).toBe(false);
}),
);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export const make = Effect.gen(function* () {
threadPinReorder: true,
threadTitleRegeneration: true,
threadAnnotations: true,
threadWorktreeCleanup: true,
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}),
},
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
titleRegenerationRequestId: null,
titleRegenerationStartedAt: null,
annotation: null,
worktreeCleanup: null,
latestUserMessageId: null,
latestUserMessageAt: null,
pendingApprovalCount: 0,
Expand Down Expand Up @@ -874,11 +875,27 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
yield* projectionThreadRepository.upsert({
...existingRow.value,
deletedAt: event.payload.deletedAt,
worktreeCleanup: event.payload.worktreeCleanup ?? null,
updatedAt: event.payload.deletedAt,
});
return;
}

case "thread.worktree-cleanup-updated": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
worktreeCleanup: event.payload.cleanup,
updatedAt: event.payload.updatedAt,
});
return;
}

case "thread.message-sent":
case "thread.proposed-plan-upserted":
case "thread.activity-appended":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1630,6 +1630,32 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
const shellSnapshot = yield* snapshotQuery.getShellSnapshot();
assert.equal(shellSnapshot.projects.length, 0);
assert.equal(shellSnapshot.threads.length, 0);

yield* sql`
UPDATE projection_projects
SET deleted_at = NULL
WHERE project_id = 'project-deleted'
`;
yield* sql`
UPDATE projection_threads
SET
worktree_path = '/tmp/deleted-project-worktrees/thread-deleted',
worktree_cleanup_json = '{"status":"deleting","repositoryRoot":"/tmp/deleted-project","worktreePath":"/tmp/deleted-project-worktrees/thread-deleted","startedAt":"2026-04-05T00:00:05.000Z"}'
WHERE thread_id = 'thread-deleted'
`;

const cleanupShellSnapshot = yield* snapshotQuery.getShellSnapshot();
assert.equal(cleanupShellSnapshot.projects.length, 1);
assert.deepStrictEqual(cleanupShellSnapshot.threads[0]?.worktreeCleanup, {
status: "deleting",
repositoryRoot: "/tmp/deleted-project",
worktreePath: "/tmp/deleted-project-worktrees/thread-deleted",
startedAt: "2026-04-05T00:00:05.000Z",
});
const cleanupDetail = yield* snapshotQuery.getThreadDetailById(
ThreadId.make("thread-deleted"),
);
assert.equal(cleanupDetail._tag, "None");
}),
);

Expand Down Expand Up @@ -2444,25 +2470,29 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
const detailWithPinnedRequests = yield* snapshotQuery.getThreadDetailById(threadW);
assert.equal(detailWithPinnedRequests._tag, "Some");
if (detailWithPinnedRequests._tag === "Some") {
const ids = detailWithPinnedRequests.value.activities.map((activity) => activity.id);
const ids = new Set(
detailWithPinnedRequests.value.activities.map((activity) => activity.id),
);
assert.equal(detailWithPinnedRequests.value.activities.length, 503);
assert.equal(ids.includes(asEventId("approval-old")), true);
assert.equal(ids.includes(asEventId("user-input-old")), true);
assert.equal(ids.includes(asEventId("user-input-closed")), false);
assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true);
assert.equal(ids.has(asEventId("approval-old")), true);
assert.equal(ids.has(asEventId("user-input-old")), true);
assert.equal(ids.has(asEventId("user-input-closed")), false);
assert.equal(ids.has(asEventId("user-input-tied-z-request")), true);
}

const windowWithPinnedRequests = yield* snapshotQuery.getThreadDetailSnapshot(threadW, {
turnLimit: 2,
});
assert.equal(windowWithPinnedRequests._tag, "Some");
if (windowWithPinnedRequests._tag === "Some") {
const ids = windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id);
const ids = new Set(
windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id),
);
assert.equal(windowWithPinnedRequests.value.thread.activities.length, 503);
assert.equal(ids.includes(asEventId("approval-old")), true);
assert.equal(ids.includes(asEventId("user-input-old")), true);
assert.equal(ids.includes(asEventId("user-input-closed")), false);
assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true);
assert.equal(ids.has(asEventId("approval-old")), true);
assert.equal(ids.has(asEventId("user-input-old")), true);
assert.equal(ids.has(asEventId("user-input-closed")), false);
assert.equal(ids.has(asEventId("user-input-tied-z-request")), true);
}
}),
);
Expand Down
Loading