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
10 changes: 8 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 @@ -58,6 +58,8 @@ const STATUS_LABEL_BY_STATUS: Partial<
input: { label: "Input", className: "text-foreground-secondary" },
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 @@ -765,11 +767,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 ? "text-user-bubble-foreground-muted" : "text-danger-foreground",
selected
? "text-user-bubble-foreground-muted"
: 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 @@ -159,6 +159,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 @@ -33,7 +33,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 @@ -144,7 +144,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 @@ -1232,6 +1232,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 @@ -365,6 +365,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 @@ -688,6 +689,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 @@ -709,6 +711,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 @@ -734,6 +737,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 @@ -1117,6 +1121,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 @@ -1376,6 +1381,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 @@ -2037,6 +2043,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
Original file line number Diff line number Diff line change
Expand Up @@ -3367,6 +3367,74 @@ 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("records runtime.error activities from the typed payload message", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1605,6 +1605,12 @@ 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.
const lastErrorClass =
status === "ready" || status === "interrupted"
? null
: (thread.session?.lastErrorClass ?? null);

if (shouldApplyThreadLifecycle) {
if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) {
Expand Down Expand Up @@ -1641,6 +1647,7 @@ const make = Effect.gen(function* () {
runtimeMode: thread.session?.runtimeMode ?? "full-access",
activeTurnId: nextActiveTurnId,
lastError,
lastErrorClass,
updatedAt: now,
},
createdAt: now,
Expand Down Expand Up @@ -1936,6 +1943,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 @@ -61,6 +61,7 @@ import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.
import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts";
import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts";
import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts";
import Migration0050 from "./Migrations/050_ProjectionThreadSessionsLastErrorClass.ts";

/**
* Migration loader with all migrations defined inline.
Expand Down Expand Up @@ -122,6 +123,7 @@ export const migrationEntries = [
[47, "ProjectionProjectIcon", Migration0047],
[48, "ProjectionThreadBranchPullRequest", Migration0048],
[49, "ProjectionThreadsActiveOrderKey", Migration0049],
[50, "ProjectionThreadSessionsLastErrorClass", Migration0050],
] as const;

export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as SqlClient from "effect/unstable/sql/SqlClient";

import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";

const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));

layer("050_ProjectionThreadSessionsLastErrorClass", (it) => {
it.effect("adds the nullable last error class to thread session projections", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

yield* runMigrations({ toMigrationInclusive: 49 });
yield* runMigrations({ toMigrationInclusive: 50 });

const columns = yield* sql<{ readonly name: string; readonly notnull: number }>`
PRAGMA table_info(projection_thread_sessions)
`;
const lastErrorClass = columns.find((column) => column.name === "last_error_class");

assert.equal(lastErrorClass?.name, "last_error_class");
assert.equal(lastErrorClass?.notnull, 0);
}),
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/unstable/sql/SqlClient";

export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const columns = yield* sql<{ readonly name: string }>`
PRAGMA table_info(projection_thread_sessions)
`;
if (!columns.some((column) => column.name === "last_error_class")) {
yield* sql`
ALTER TABLE projection_thread_sessions
ADD COLUMN last_error_class TEXT
`;
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import {
RuntimeMode,
IsoDateTime,
OrchestrationSessionErrorClass,
OrchestrationSessionStatus,
ProviderInstanceId,
ThreadId,
Expand All @@ -29,6 +30,7 @@ export const ProjectionThreadSession = Schema.Struct({
runtimeMode: RuntimeMode,
activeTurnId: Schema.NullOr(TurnId),
lastError: Schema.NullOr(Schema.String),
lastErrorClass: Schema.NullOr(OrchestrationSessionErrorClass),
updatedAt: IsoDateTime,
});
export type ProjectionThreadSession = typeof ProjectionThreadSession.Type;
Expand Down
16 changes: 14 additions & 2 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2302,13 +2302,21 @@ describe("ClaudeAdapterLive", () => {
uuid: "result-auth",
} as unknown as SDKMessage);

const payload = completedTurn(Array.from(yield* Fiber.join(runtimeEventsFiber)));
const events = Array.from(yield* Fiber.join(runtimeEventsFiber));
const payload = completedTurn(events);
assert.equal(payload.state, state);
if (errorMessage === undefined) {
assert.equal(payload.errorMessage, undefined);
} else {
assert.match(payload.errorMessage ?? "", errorMessage);
}
// Only a usage limit is classed as one; every other failure stays a
// provider error so clients keep reading it as Failed.
for (const event of events) {
if (event.type === "runtime.error") {
assert.equal(event.payload.class, "provider_error");
}
}
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
Expand Down Expand Up @@ -2355,12 +2363,16 @@ describe("ClaudeAdapterLive", () => {
uuid: "result-limit",
} as unknown as SDKMessage);

const payload = completedTurn(Array.from(yield* Fiber.join(runtimeEventsFiber)));
const events = Array.from(yield* Fiber.join(runtimeEventsFiber));
const payload = completedTurn(events);
assert.equal(payload.state, "failed");
assert.equal(
payload.errorMessage,
"Claude usage limit reached. Send the message again once the limit resets.",
);
const runtimeError = events.find((event) => event.type === "runtime.error");
assert(runtimeError?.type === "runtime.error");
assert.equal(runtimeError.payload.class, "usage_limit");
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
Expand Down
Loading
Loading