Skip to content
Open
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
8 changes: 6 additions & 2 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ const STATUS_LABEL_BY_STATUS: Partial<
input: { label: "Input", className: "text-adaptive-indigo-600-300" },
working: { label: "Working", className: "text-adaptive-sky-600-400" },
failed: { label: "Failed", className: "text-danger-foreground" },
// A usage limit is a wait, not a break, so it takes the approval tone.
limited: { label: "Limited", className: "text-warning-foreground" },
};

function threadTimeLabel(thread: EnvironmentThreadShell): string {
Expand Down Expand Up @@ -890,13 +892,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
</View>
) : null}
<View className="mt-1 flex-row items-center gap-2">
{status === "failed" && thread.session?.lastError ? (
{(status === "failed" || status === "limited") && thread.session?.lastError ? (
<Text
className={cn(
"flex-1 text-xs",
selected
? selectedThreadRowColors.mutedForegroundClassName
: "text-danger-foreground",
: status === "limited"
? "text-warning-foreground"
: "text-danger-foreground",
)}
numberOfLines={1}
>
Expand Down
22 changes: 22 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,28 @@ describe("resolveThreadListV2Status", () => {
expect(resolveThreadListV2Status(thread)).toBe("approval");
});

it("resolves limited only when a usage limit stopped the session", () => {
const errored = (lastErrorClass: "usage_limit" | null) =>
makeThread({
id: ThreadId.make("t"),
title: "t",
session: {
threadId: ThreadId.make("t"),
status: "error",
providerName: "Claude",
providerInstanceId: ProviderInstanceId.make("claude"),
runtimeMode: "full-access",
activeTurnId: null,
lastError: "stopped",
lastErrorClass,
updatedAt: NOW,
},
});

expect(resolveThreadListV2Status(errored("usage_limit"))).toBe("limited");
expect(resolveThreadListV2Status(errored(null))).toBe("failed");
});

it("resolves ready for quiescent threads", () => {
expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe(
"ready",
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export { snoozeWakeLabel };
* (approval), "in motion" (working), and "broken" (failed). Ready is the
* unlabeled resting state.
*/
export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";
export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "limited" | "ready";
export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze";

export function resolveThreadListV2SnoozeMenuSelection(input: {
Expand Down Expand Up @@ -145,7 +145,7 @@ export function resolveThreadListV2Status(
return "working";
}
if (thread.session?.status === "error") {
return "failed";
return thread.session.lastErrorClass === "usage_limit" ? "limited" : "failed";
}
return "ready";
}
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1355,6 +1355,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
runtimeMode: event.payload.session.runtimeMode,
activeTurnId: event.payload.session.activeTurnId,
lastError: event.payload.session.lastError,
lastErrorClass: event.payload.session.lastErrorClass ?? null,
updatedAt: event.payload.session.updatedAt,
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ function mapSessionRow(
runtimeMode: row.runtimeMode,
activeTurnId: row.activeTurnId,
lastError: row.lastError,
...(row.lastErrorClass !== null ? { lastErrorClass: row.lastErrorClass } : {}),
updatedAt: row.updatedAt,
};
}
Expand Down Expand Up @@ -862,6 +863,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
runtime_mode AS "runtimeMode",
active_turn_id AS "activeTurnId",
last_error AS "lastError",
last_error_class AS "lastErrorClass",
updated_at AS "updatedAt"
FROM projection_thread_sessions
ORDER BY thread_id ASC
Expand All @@ -883,6 +885,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
sessions.runtime_mode AS "runtimeMode",
sessions.active_turn_id AS "activeTurnId",
sessions.last_error AS "lastError",
sessions.last_error_class AS "lastErrorClass",
sessions.updated_at AS "updatedAt"
FROM projection_thread_sessions sessions
INNER JOIN projection_threads threads
Expand All @@ -908,6 +911,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
sessions.runtime_mode AS "runtimeMode",
sessions.active_turn_id AS "activeTurnId",
sessions.last_error AS "lastError",
sessions.last_error_class AS "lastErrorClass",
sessions.updated_at AS "updatedAt"
FROM projection_thread_sessions sessions
INNER JOIN projection_threads threads
Expand Down Expand Up @@ -1298,6 +1302,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
sessions.runtime_mode AS "runtimeMode",
sessions.active_turn_id AS "activeTurnId",
sessions.last_error AS "lastError",
sessions.last_error_class AS "lastErrorClass",
sessions.updated_at AS "updatedAt"
FROM projection_threads AS threads
LEFT JOIN projection_thread_sessions AS sessions
Expand Down Expand Up @@ -1616,6 +1621,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
runtime_mode AS "runtimeMode",
active_turn_id AS "activeTurnId",
last_error AS "lastError",
last_error_class AS "lastErrorClass",
updated_at AS "updatedAt"
FROM projection_thread_sessions
WHERE thread_id = ${threadId}
Expand Down Expand Up @@ -2289,6 +2295,7 @@ pending_approval_requests AS (
runtimeMode: row.runtimeMode,
activeTurnId: row.activeTurnId,
lastError: row.lastError,
...(row.lastErrorClass !== null ? { lastErrorClass: row.lastErrorClass } : {}),
updatedAt: row.updatedAt,
});
}
Expand Down
146 changes: 146 additions & 0 deletions apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3929,6 +3929,152 @@ describe("ProviderRuntimeIngestion", () => {
);
expect(thread.session?.status).toBe("error");
expect(thread.session?.lastError).toBe("runtime exploded");
expect(thread.session?.lastErrorClass ?? null).toBeNull();
});

it("carries a usage-limit class from runtime.error through the failed turn", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";

harness.emit({
type: "turn.started",
eventId: asEventId("evt-limit-turn-started"),
provider: ProviderDriverKind.make("claude"),
threadId: asThreadId("thread-1"),
createdAt: now,
turnId: asTurnId("turn-limit"),
});

harness.emit({
type: "runtime.error",
eventId: asEventId("evt-limit-runtime-error"),
provider: ProviderDriverKind.make("claude"),
createdAt: now,
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-limit"),
payload: {
message: "Claude usage limit reached.",
class: "usage_limit",
},
});

await waitForThread(
harness.readModel,
(entry) =>
entry.session?.status === "error" && entry.session?.lastErrorClass === "usage_limit",
);

harness.emit({
type: "turn.completed",
eventId: asEventId("evt-limit-turn-completed"),
provider: ProviderDriverKind.make("claude"),
threadId: asThreadId("thread-1"),
createdAt: now,
turnId: asTurnId("turn-limit"),
payload: {
state: "failed",
errorMessage: "Claude usage limit reached.",
},
});

const failed = await waitForThread(
harness.readModel,
(entry) => entry.session?.status === "error" && entry.session?.activeTurnId === null,
);
expect(failed.session?.lastErrorClass).toBe("usage_limit");

harness.emit({
type: "session.state.changed",
eventId: asEventId("evt-limit-session-ready"),
provider: ProviderDriverKind.make("claude"),
threadId: asThreadId("thread-1"),
createdAt: now,
payload: { state: "ready" },
});

const ready = await waitForThread(
harness.readModel,
(entry) => entry.session?.status === "ready",
);
expect(ready.session?.lastErrorClass ?? null).toBeNull();
});

it("drops the usage-limit class when a session error replaces the classified one", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";

harness.emit({
type: "runtime.error",
eventId: asEventId("evt-limit-before-replace"),
provider: ProviderDriverKind.make("claude"),
createdAt: now,
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-replace"),
payload: {
message: "Claude usage limit reached.",
class: "usage_limit",
},
});

await waitForThread(
harness.readModel,
(entry) => entry.session?.lastErrorClass === "usage_limit",
);

harness.emit({
type: "session.state.changed",
eventId: asEventId("evt-session-error-replace"),
provider: ProviderDriverKind.make("claude"),
threadId: asThreadId("thread-1"),
createdAt: now,
payload: { state: "error", reason: "Socket closed" },
});

const replaced = await waitForThread(
harness.readModel,
(entry) => entry.session?.lastError === "Socket closed",
);
expect(replaced.session?.status).toBe("error");
expect(replaced.session?.lastErrorClass ?? null).toBeNull();
});

it("drops the usage-limit class when a failed turn replaces the error", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";

harness.emit({
type: "runtime.error",
eventId: asEventId("evt-limit-before-turn-fail"),
provider: ProviderDriverKind.make("claude"),
createdAt: now,
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-replace"),
payload: {
message: "Claude usage limit reached.",
class: "usage_limit",
},
});

await waitForThread(
harness.readModel,
(entry) => entry.session?.lastErrorClass === "usage_limit",
);

harness.emit({
type: "turn.completed",
eventId: asEventId("evt-turn-fail-replace"),
provider: ProviderDriverKind.make("claude"),
threadId: asThreadId("thread-1"),
createdAt: now,
turnId: asTurnId("turn-replace"),
payload: { state: "failed", errorMessage: "Transport closed" },
});

const replaced = await waitForThread(
harness.readModel,
(entry) => entry.session?.lastError === "Transport closed",
);
expect(replaced.session?.lastErrorClass ?? null).toBeNull();
});

it("records runtime.error activities from the typed payload message", async () => {
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1886,6 +1886,16 @@ const make = Effect.gen(function* () {
: status === "ready" || status === "interrupted"
? null
: (thread.session?.lastError ?? null);
// Set by the runtime.error that precedes a failed turn.completed, so
// it rides along with lastError instead of being re-derived here. An
// event that replaces the stored error describes a different failure,
// so the inherited class must not outlive the error it classified.
const lastErrorClass =
status === "ready" ||
status === "interrupted" ||
lastError !== (thread.session?.lastError ?? null)
? null
: (thread.session?.lastErrorClass ?? null);

if (shouldApplyThreadLifecycle) {
if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) {
Expand Down Expand Up @@ -1922,6 +1932,7 @@ const make = Effect.gen(function* () {
runtimeMode: thread.session?.runtimeMode ?? "full-access",
activeTurnId: nextActiveTurnId,
lastError,
lastErrorClass,
updatedAt: now,
},
createdAt: now,
Expand Down Expand Up @@ -2422,6 +2433,7 @@ const make = Effect.gen(function* () {
runtimeMode: thread.session?.runtimeMode ?? "full-access",
activeTurnId: eventTurnId ?? null,
lastError: runtimeErrorMessage,
lastErrorClass: event.payload.class === "usage_limit" ? "usage_limit" : null,
updatedAt: now,
},
createdAt: now,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {
runtime_mode,
active_turn_id,
last_error,
last_error_class,
updated_at
)
VALUES (
Expand All @@ -38,6 +39,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {
${row.runtimeMode},
${row.activeTurnId},
${row.lastError},
${row.lastErrorClass},
${row.updatedAt}
)
ON CONFLICT (thread_id)
Expand All @@ -48,6 +50,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {
runtime_mode = excluded.runtime_mode,
active_turn_id = excluded.active_turn_id,
last_error = excluded.last_error,
last_error_class = excluded.last_error_class,
updated_at = excluded.updated_at
`,
});
Expand All @@ -65,6 +68,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () {
runtime_mode AS "runtimeMode",
active_turn_id AS "activeTurnId",
last_error AS "lastError",
last_error_class AS "lastErrorClass",
updated_at AS "updatedAt"
FROM projection_thread_sessions
WHERE thread_id = ${threadId}
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts";
import Migration0051 from "./Migrations/051_ProjectionThreadMessageContext.ts";
import Migration0052 from "./Migrations/052_ProjectionThreadTitleState.ts";
import Migration0053 from "./Migrations/053_PullRequestFilesViewed.ts";
import Migration0054 from "./Migrations/054_ProjectionThreadSessionsLastErrorClass.ts";

/**
* Migration loader with all migrations defined inline.
Expand Down Expand Up @@ -130,6 +131,7 @@ const migrationEntries = [
[51, "ProjectionThreadMessageContext", Migration0051],
[52, "ProjectionThreadTitleState", Migration0052],
[53, "PullRequestFilesViewed", Migration0053],
[54, "ProjectionThreadSessionsLastErrorClass", Migration0054],
] as const;

export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
Expand Down
Loading
Loading