Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b2c7842
feat(lastcode): recover stale Codex and Claude runs
lastobelus Oct 5, 2026
55a3bf7
fix(lastcode): preserve actionable recovery across lifecycle failures
lastobelus Oct 5, 2026
a041e56
fix(lastcode): guard recovery notices against superseded runs
lastobelus Oct 5, 2026
793c0b5
fix(lastcode): require a durable provider turn before recovery
lastobelus Oct 5, 2026
a7b6815
fix(lastcode): retain successor recovery registrations
lastobelus Oct 5, 2026
d693c1a
fix(lastcode): create repair threads with valid user provenance
lastobelus Oct 5, 2026
a74445e
fix(lastcode): clear recovery when queued successors start
lastobelus Oct 5, 2026
ed1439f
fix(lastcode): synchronize repair navigation and encode recovery details
lastobelus Oct 5, 2026
5dad786
fix(mcp): label thread recovery tools
lastobelus Oct 5, 2026
0424a0a
fix(ci): align recovery summaries and migration expectations
lastobelus Oct 5, 2026
fa0acaa
fix(ci): correct migration ledger tests and repair wait error
lastobelus Oct 5, 2026
e1a0aad
fix(recovery): preserve linked repair conversations on retry
lastobelus Oct 5, 2026
b44daca
fix(recovery): reject active probes from released provider sessions
lastobelus Oct 5, 2026
fe3872a
fix(recovery): keep exhausted incidents from delayed suspicion
lastobelus Oct 5, 2026
94a327b
fix(test): retain literal MCP capability type
lastobelus Oct 5, 2026
8ebc699
fix(recovery): validate repair links and serialize recovery launches
lastobelus Oct 5, 2026
fed854b
docs(agents): finish findings from the final local review
lastobelus Oct 5, 2026
bbd64dd
fix(server): safely accept repairs and settle released recovery work
lastobelus Oct 5, 2026
e3829a6
fix(server): drain native completion events before recovery settlement
lastobelus Oct 5, 2026
acd71d7
fix(server): close released recovery readers and preserve restarts
lastobelus Oct 5, 2026
1ef30ab
fix(server): drain background work after every recovered terminal
lastobelus Oct 5, 2026
fd74fe6
fix(server): retain terminal evidence across unrelated completions
lastobelus Oct 6, 2026
e25218b
fix(server): release terminal evidence after durable finalization
lastobelus Oct 6, 2026
f3bfbd4
fix(mcp): align recovery with current caller scopes
lastobelus Oct 6, 2026
89bb2d4
fix(clients): queue follow-ups while thread recovery is active
lastobelus Oct 6, 2026
0eb8b8b
fix(server): retry recovered background settlement after write failures
lastobelus Oct 6, 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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ and restart remain separate decisions. If another Action continuation is pending
end the turn so its result can arrive, then list Actions again. An automated
message alone does not make Actions unavailable; use the actual disabled reason.

## Local Reviews

When given a budget for local reviews (e.g., example: "up to 5 rounds of Astra High review" or "review (up to 3 rounds)", run the reviews & address issues until a review comes back clean, or the budget is exhausted. Always finish addressing issues from the last review! Then proceed (if instructed) with babysitting the pr (addressing ci failures and github review comments). If instructed to merge, do so when ci/github-reviews are clean, even if the last round of local review was not clean. If instructed to "merge if clean", proceed through babysit pr but don't merge if the last local review was not clean, and notify the user.

## Pull requests

- Never make a PR unless the developer explicitly asks you to do so.
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ export interface ThreadComposerProps {
readonly followUpBehavior: FollowUpBehavior;
/** Whether the live turn can actually be steered by this provider. */
readonly canSteerActiveTurn: boolean;
/** Recovery requires new follow-ups to wait even if the runtime looks idle. */
readonly forceQueue: boolean;
readonly editorRef?: RefObject<ComposerEditorHandle | null>;
readonly onChangeDraftMessage: (value: string) => void;
readonly onPickDraftMedia: () => Promise<void>;
Expand Down Expand Up @@ -424,6 +426,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
editingQueuedMessage: queuedEdit !== null,
running: props.activeThreadBusy,
canSteer: props.canSteerActiveTurn,
forceQueue: props.forceQueue,
followUpBehavior: props.followUpBehavior,
deliveryDeferred:
props.connectionState !== "connected" || props.queueCount > 0 || attachmentsUploading,
Expand Down
15 changes: 13 additions & 2 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { recoverySuppressesWorking } from "@t3tools/client-runtime/state/thread-recovery";
import { ThreadRecoveryNotice } from "./ThreadRecoveryNotice";
import { useAtomValue } from "@effect/atom-react";
import { useThreadReportedModelSelection } from "../../state/entities";
import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
Expand Down Expand Up @@ -190,6 +192,7 @@ export interface ThreadDetailScreenProps {
readonly composerDraftKey: string | null;
readonly followUpBehavior: FollowUpBehavior;
readonly canSteerActiveTurn: boolean;
readonly forceQueue: boolean;
readonly isSavingQueuedEdit: boolean;
readonly onCancelQueuedRunEdit: () => void;
readonly onRemoveQueuedEditAttachment: (attachmentId: string) => void;
Expand Down Expand Up @@ -451,6 +454,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const pendingBackgroundWork = presentPendingBackgroundWork(
props.selectedThread.pendingBackgroundTasks,
);
const suppressStaleWorking = recoverySuppressesWorking(props.selectedThread.recovery);
const floatingStatus = ((): FloatingWorkingStatus | null => {
const connectionStatus = connectionFloatingStatus({
connectionError: props.connectionError,
Expand All @@ -461,6 +465,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
if (connectionStatus !== null) {
return connectionStatus;
}
if (suppressStaleWorking) return null;
if (props.activePendingApproval !== null || props.activePendingUserInput !== null) {
return null;
}
Expand Down Expand Up @@ -1100,8 +1105,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
agentLabel={agentLabel}
threadTitle={props.selectedThread.title}
latestRun={props.activityRun}
activeWorkStartedAt={props.activeWorkStartedAt}
runlessWorkActive={props.runlessWorkActive ?? false}
activeWorkStartedAt={suppressStaleWorking ? null : props.activeWorkStartedAt}
runlessWorkActive={!suppressStaleWorking && (props.runlessWorkActive ?? false)}
listRef={listRef}
freeze={freeze}
anchorMessageId={anchorMessageId}
Expand Down Expand Up @@ -1194,6 +1199,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
/>
</Animated.View>
) : null}
<ThreadRecoveryNotice
key={`${selectedThreadKey}:${props.selectedThread.recovery?.runId}:${props.selectedThread.recovery?.attemptId}`}
thread={props.selectedThread}
environmentId={props.environmentId}
/>
<UsageLimitRecoveryCard
key={props.selectedThread.latestRun?.runId}
thread={props.selectedThread}
Expand Down Expand Up @@ -1419,6 +1429,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
draftKey={props.composerDraftKey ?? undefined}
followUpBehavior={props.followUpBehavior}
canSteerActiveTurn={props.canSteerActiveTurn}
forceQueue={props.forceQueue}
queuedEdit={
props.queuedRunEdit === null
? null
Expand Down
111 changes: 111 additions & 0 deletions apps/mobile/src/features/threads/ThreadRecoveryNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { useNavigation } from "@react-navigation/native";
import { presentThreadRecovery } from "@t3tools/client-runtime/state/thread-recovery";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import type { EnvironmentId } from "@t3tools/contracts";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { View } from "react-native";
import { AppText as Text } from "../../components/AppText";
import { ControlPill } from "../../components/ControlPill";
import { useThreadShell } from "../../state/entities";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";

export function ThreadRecoveryNotice({
thread,
environmentId,
}: {
thread: EnvironmentThreadShell;
environmentId: EnvironmentId;
}) {
const navigation = useNavigation();
const recover = useAtomCommand(threadEnvironment.recoverThread, { reportFailure: false });
const repair = useAtomCommand(threadEnvironment.repairThread, { reportFailure: false });
const [pendingKey, setPendingKey] = useState<string | null>(null);
const [error, setError] = useState<{ key: string; message: string } | null>(null);
const [dismissedKey, setDismissedKey] = useState<string | null>(null);
const recovery = thread.recovery;
const repairThread = useThreadShell(
recovery?.repairThreadId ? { environmentId, threadId: recovery.repairThreadId } : null,
);
const key = `${environmentId}:${thread.id}:${recovery?.runId}:${recovery?.attemptId}`;
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const currentKey = useRef(key);
useLayoutEffect(() => {
currentKey.current = key;
}, [key]);
const presentation = presentThreadRecovery(
recovery,
repairThread !== null && repairThread.deletedAt === null,
);
if (!recovery || !presentation || (dismissedKey === key && recovery.status === "recovered"))
return null;
const pending = pendingKey === key;
const act = async () => {
if (pending || presentation.busy) return;
setPendingKey(key);
setError(null);
const input = { threadId: thread.id, runId: recovery.runId, attemptId: recovery.attemptId };
try {
if (presentation.action === "launch-repair" || presentation.action === "view-repair") {
const result = await repair({ environmentId, input });
if (result._tag === "Failure") throw squashAtomCommandFailure(result);
if (mounted.current && currentKey.current === key && navigation.isFocused())
navigation.navigate("Thread", { environmentId, threadId: result.value.threadId });
} else {
const result = await recover({ environmentId, input });
if (result._tag === "Failure") throw squashAtomCommandFailure(result);
}
} catch (cause) {
setError({
key,
message: cause instanceof Error ? cause.message : "Could not recover this run.",
});
} finally {
setPendingKey((value) => (value === key ? null : value));
}
};
return (
<View className="mx-3 mb-2 gap-2 rounded-xl border border-warning-foreground/25 bg-background p-3">
<Text
accessibilityLiveRegion="polite"
accessibilityRole="header"
className="text-sm font-t3-bold text-foreground"
>
{presentation.title}
</Text>
<Text className="text-xs text-muted-foreground">{presentation.description}</Text>
<View className="flex-row justify-end">
{presentation.label ? (
<ControlPill
label={
pending
? presentation.action === "launch-repair" || presentation.action === "view-repair"
? "Opening…"
: recovery.status === "suspect"
? "Checking…"
: "Recovering…"
: presentation.label
}
disabled={pending || presentation.busy}
onPress={() => void act()}
variant="pill"
/>
) : (
<ControlPill label="Dismiss" onPress={() => setDismissedKey(key)} variant="pill" />
)}
Comment thread
lastobelus marked this conversation as resolved.
</View>
{error?.key === key ? (
<Text accessibilityRole="alert" className="text-sm text-destructive">
{error.message}
</Text>
) : null}
</View>
);
}
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,7 @@ function ThreadRouteContent(
composerDraftKey={composer.composerDraftKey}
followUpBehavior={composer.followUpBehavior}
canSteerActiveTurn={composer.canSteerActiveTurn}
forceQueue={composer.forceQueue}
isSavingQueuedEdit={composer.isSavingQueuedEdit}
onCancelQueuedRunEdit={composer.cancelQueuedRunEdit}
onRemoveQueuedEditAttachment={composer.onRemoveQueuedEditAttachment}
Expand Down
63 changes: 63 additions & 0 deletions apps/mobile/src/features/threads/composerSendPresentation.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { recoveryQueuesFollowUps } from "@t3tools/client-runtime/state/thread-recovery";
import { RunAttemptId, RunId, type OrchestrationV2ThreadRecovery } from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import { describe, expect, it } from "vite-plus/test";

import { resolveComposerSendPresentation } from "./composerSendPresentation";
Expand All @@ -6,10 +9,21 @@ const idle = {
editingQueuedMessage: false,
running: false,
canSteer: false,
forceQueue: false,
followUpBehavior: "queue",
deliveryDeferred: false,
} as const;

function recovery(status: OrchestrationV2ThreadRecovery["status"]): OrchestrationV2ThreadRecovery {
return {
runId: RunId.make("recovering-run"),
attemptId: RunAttemptId.make("recovering-attempt"),
status,
detail: "The provider's turn is being checked.",
updatedAt: DateTime.makeUnsafe("2026-01-01T00:00:00Z"),
};
}

describe("resolveComposerSendPresentation", () => {
it("sends plainly while the thread is idle", () => {
const presentation = resolveComposerSendPresentation(idle);
Expand Down Expand Up @@ -65,17 +79,66 @@ describe("resolveComposerSendPresentation", () => {
expect(presentation.offersFollowUpChoice).toBe(false);
});

it.each(["suspect", "stale", "recovering", "failed"] as const)(
"queues follow-ups without a steer choice during %s recovery",
(status) => {
const incident = recovery(status);
for (const running of [true, false]) {
const presentation = resolveComposerSendPresentation({
...idle,
running,
canSteer: true,
followUpBehavior: "steer",
forceQueue: recoveryQueuesFollowUps(incident, incident.runId),
});

expect(presentation.label).toBe("Queue");
expect(presentation.icon).toBe("list.number");
expect(presentation.action).toBe("queue");
expect(presentation.alternate).toBeNull();
expect(presentation.offersFollowUpChoice).toBe(false);
}
},
);

it("restores ordinary send choices after recovery or when another run is active", () => {
const recovered = recovery("recovered");
const previousIncident = recovery("failed");
for (const forceQueue of [
recoveryQueuesFollowUps(recovered, recovered.runId),
recoveryQueuesFollowUps(previousIncident, RunId.make("new-run")),
recoveryQueuesFollowUps(previousIncident, null),
]) {
const presentation = resolveComposerSendPresentation({
...idle,
running: true,
canSteer: true,
followUpBehavior: "steer",
forceQueue,
});

expect(presentation.label).toBe("Steer");
expect(presentation.action).toBe("steer");
expect(presentation.alternate).toBe("queue");
expect(presentation.offersFollowUpChoice).toBe(true);
expect(resolveComposerSendPresentation({ ...idle, forceQueue }).label).toBe("Send");
}
});

it("keeps the save affordance while a queued message is being edited", () => {
const presentation = resolveComposerSendPresentation({
...idle,
editingQueuedMessage: true,
running: true,
canSteer: true,
followUpBehavior: "steer",
forceQueue: true,
});

expect(presentation.label).toBe("Update queued message");
expect(presentation.icon).toBe("checkmark");
expect(presentation.action).toBeNull();
expect(presentation.alternate).toBeNull();
expect(presentation.offersFollowUpChoice).toBe(false);
});
});
12 changes: 11 additions & 1 deletion apps/mobile/src/features/threads/composerSendPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { FollowUpBehavior } from "../../lib/followUpBehavior";
export interface ComposerSendPresentation {
readonly label: string;
readonly icon: "arrow.up" | "checkmark" | "list.number" | "arrow.turn.left.up";
/** What a plain tap does while a turn runs, or null when the turn is idle. */
/** What a plain tap does for a follow-up, or null for an ordinary idle send. */
readonly action: ActiveTurnComposerAction | null;
/** What the long-press menu and the Command chord do instead. */
readonly alternate: ActiveTurnComposerAction | null;
Expand All @@ -31,6 +31,7 @@ export function resolveComposerSendPresentation(input: {
readonly editingQueuedMessage: boolean;
readonly running: boolean;
readonly canSteer: boolean;
readonly forceQueue: boolean;
readonly followUpBehavior: FollowUpBehavior;
/** Outbox reasons the send waits rather than leaving immediately. */
readonly deliveryDeferred: boolean;
Expand All @@ -44,6 +45,15 @@ export function resolveComposerSendPresentation(input: {
offersFollowUpChoice: false,
};
}
if (input.forceQueue) {
return {
label: "Queue",
icon: "list.number",
action: "queue",
alternate: null,
offersFollowUpChoice: false,
};
}
if (!input.running) {
return {
label: input.deliveryDeferred ? "Queue" : "Send",
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ import { ThreadSearchMatchExcerpt } from "./thread-search-match";
const STATUS_LABEL_BY_STATUS: Partial<
Record<ThreadListV2Status, { label: string; className: string }>
> = {
"not-responding": { label: "Not responding", className: "text-warning-foreground" },
"needs-repair": { label: "Needs repair", className: "text-warning-foreground" },
approval: { label: "Approval", className: "text-warning-foreground" },
input: { label: "Input", className: "text-adaptive-indigo-600-300" },
question: { label: "? Question", className: "text-adaptive-violet-700-300" },
Expand Down
13 changes: 13 additions & 0 deletions apps/mobile/src/features/threads/thread-status.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { threadRecoveryStatusLabel } from "@t3tools/client-runtime/state/thread-recovery";
import type { StatusTone } from "../../components/StatusPill";
import {
threadRuntimeIsActive,
Expand All @@ -11,6 +12,8 @@ export type ThreadStatusKind =
| "pending-approval"
| "awaiting-input"
| "question"
| "not-responding"
| "needs-repair"
| "working"
| "waiting"
| "connecting"
Expand Down Expand Up @@ -84,6 +87,16 @@ export function resolveThreadStatus(
};
}

const recoveryLabel = threadRecoveryStatusLabel(thread.recovery);
if (recoveryLabel)
return {
kind: recoveryLabel === "Needs repair" ? "needs-repair" : "not-responding",
label: recoveryLabel,
pillClassName: "bg-warning",
textClassName: "text-warning-foreground",
pulse: false,
};

if (thread.hasPendingApprovals) {
return {
kind: "pending-approval",
Expand Down
Loading