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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
run: vp run --filter @t3tools/desktop ensure:electron

- name: Check
run: vp check
run: vp check 2>&1 | tee "$RUNNER_TEMP/vp-check.log"

- name: Typecheck
run: vpr typecheck
Expand Down
31 changes: 19 additions & 12 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,11 @@ interface PendingCodexSubagentTurnStarted {
readonly startedAt: DateTime.Utc;
}

interface PendingCodexRootTurn {
readonly turnInput: ProviderAdapterV2TurnInput;
readonly started: Deferred.Deferred<ActiveCodexTurnContext, never>;
}

type PendingCodexRuntimeRequest =
| {
readonly type: "approval";
Expand Down Expand Up @@ -1473,7 +1478,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
});
const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
const activeTurns = yield* Ref.make(new Map<string, ActiveCodexTurnContext>());
const pendingRootTurns = yield* Ref.make(new Map<string, ProviderAdapterV2TurnInput>());
const pendingRootTurns = yield* Ref.make(new Map<string, PendingCodexRootTurn>());
const turnWaiters = yield* Ref.make(new Map<string, Deferred.Deferred<void, never>>());
const subagentThreads = yield* Ref.make(new Map<string, CodexSubagentThreadContext>());
const pendingSubagentTurns = yield* Ref.make(
Expand Down Expand Up @@ -3215,11 +3220,12 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
}
const pendingRootTurn = (yield* Ref.get(pendingRootTurns)).get(payload.threadId);
if (pendingRootTurn !== undefined) {
yield* registerRootTurn({
turnInput: pendingRootTurn,
const rootTurn = yield* registerRootTurn({
turnInput: pendingRootTurn.turnInput,
nativeTurnId: payload.turn.id,
startedAt: codexTimestamp(payload.turn.startedAt),
});
yield* Deferred.succeed(pendingRootTurn.started, rootTurn);
yield* Ref.update(pendingRootTurns, (current) => {
const updated = new Map(current);
updated.delete(payload.threadId);
Expand Down Expand Up @@ -4486,20 +4492,21 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
hasT3Mcp:
McpProviderSession.readMcpProviderSession(turnInput.threadId) !== undefined,
});
const turnStarted = yield* Deferred.make<ActiveCodexTurnContext>();
yield* Ref.update(pendingRootTurns, (current) => {
const updated = new Map(current);
updated.set(threadId, turnInput);
updated.set(threadId, { turnInput, started: turnStarted });
return updated;
});
const started = yield* client.request("turn/start", turnStartParams);
const nativeTurnId = started.turn.id;
const startedAt = codexTimestamp(started.turn.startedAt);
yield* registerRootTurn({ turnInput, nativeTurnId, startedAt });
yield* Ref.update(pendingRootTurns, (current) => {
const updated = new Map(current);
updated.delete(threadId);
return updated;
});
const rootTurn = yield* Deferred.await(turnStarted);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop waiting when Codex exits before turn/started

When Codex returns a successful turn/start response and then exits or emits an invalid/missing turn/started notification, this local deferred is never completed or failed. The existing replay preamble in CodexAdapterV2.test.ts explicitly permits the response to arrive before the notification, so an exit in that interval leaves startTurn suspended indefinitely, the run stuck during startup, and the provider session marked busy; race this wait against transport termination or otherwise provide a failure path.

Useful? React with 👍 / 👎.

if (started.turn.id !== rootTurn.nativeTurnId) {
yield* Effect.logWarning("orchestration-v2.codex-turn-id-mismatch", {
nativeThreadId: threadId,
responseNativeTurnId: started.turn.id,
notificationNativeTurnId: rootTurn.nativeTurnId,
});
Comment on lines +4503 to +4508

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add coverage for divergent Codex turn IDs

This backend behavior change has no focused regression test: the existing replay preamble supplies the same ID in the turn/start response and turn/started notification, so it cannot prove that provider events and later steering use the notification ID when the two diverge. Add a fixture with distinct IDs and assert that the registered provider turn and expectedTurnId follow the notification.

AGENTS.md reference: AGENTS.md:L110-L110

Useful? React with 👍 / 👎.

}
}).pipe(
Effect.ensuring(
Effect.flatMap(getNativeThreadId(turnInput.providerThread), (threadId) =>
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/components/ProjectScriptsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,11 @@ export default function ProjectScriptsControl({
<Button
size="xs"
variant={isPanel ? "ghost" : "outline"}
className={isPanel ? THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS : undefined}
className={cn(
isPanel
? THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS
: "w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]",
)}
aria-label={`Run ${primaryScript.name}`}
onClick={() => onRunScript(primaryScript)}
/>
Expand Down Expand Up @@ -492,7 +496,11 @@ export default function ProjectScriptsControl({
<Button
size="xs"
variant={isPanel ? "ghost" : "outline"}
className={isPanel ? THREAD_DETAILS_PANEL_ROW_CLASS : undefined}
className={cn(
isPanel
? THREAD_DETAILS_PANEL_ROW_CLASS
: "w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]",
)}
aria-label={isPanel ? "Add project script" : "Add action"}
onClick={openAddDialog}
/>
Expand Down
27 changes: 12 additions & 15 deletions apps/web/src/lib/imageCompression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,27 +140,28 @@ function createCanvas(width: number, height: number): Canvas2D | null {
* and it keeps alpha, so screenshots with transparency survive intact.
* Browsers that can't encode it silently fall back to JPEG.
*/
async function encodeToDataUrl(
async function encodeCanvas(
canvas: OffscreenCanvas | HTMLCanvasElement,
quality: number,
mimeType: string,
): Promise<{ dataUrl: string; mimeType: string } | null> {
budgetChars: number,
): Promise<{ dataUrl: string | null; mimeType: string } | null> {
if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) {
const dataUrl = canvas.toDataURL(mimeType, quality);
// toDataURL silently returns a PNG when the requested type is unsupported.
if (!dataUrl.startsWith(`data:${mimeType}`)) return null;
return { dataUrl, mimeType };
return { dataUrl: dataUrl.length <= budgetChars ? dataUrl : null, mimeType };
}
const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality });
if (blob.type && blob.type !== mimeType) return null;
const dataUrlLength = `data:${mimeType};base64,`.length + 4 * Math.ceil(blob.size / 3);
if (dataUrlLength > budgetChars) return { dataUrl: null, mimeType };
return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType };
}

/**
* Draws `bitmap` scaled to fit `maxDimension` and encodes it, stepping
* quality down until the data URL fits `budgetChars`. Returns the smallest
* encoding produced, even if it still exceeds the budget, so the caller can
* decide whether to keep or drop it.
* quality down until the data URL fits `budgetChars`.
*/
async function encodeWithinBudget(
bitmap: ImageBitmap,
Expand All @@ -175,7 +176,7 @@ async function encodeWithinBudget(

// Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to
// happen before drawing and depends on which codec we end up using.
const probe = await encodeToDataUrl(target.canvas, QUALITY_STEPS[0], "image/webp");
const probe = await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp", 0);
const mimeType = probe ? "image/webp" : "image/jpeg";

if (mimeType === "image/jpeg") {
Expand All @@ -184,18 +185,14 @@ async function encodeWithinBudget(
}
target.context.drawImage(bitmap, 0, 0, width, height);

let smallest: { dataUrl: string; mimeType: string } | null = null;
for (const quality of QUALITY_STEPS) {
const encoded = await encodeToDataUrl(target.canvas, quality, mimeType);
const encoded = await encodeCanvas(target.canvas, quality, mimeType, budgetChars);
if (!encoded) break;
if (smallest === null || encoded.dataUrl.length < smallest.dataUrl.length) {
smallest = encoded;
}
if (encoded.dataUrl.length <= budgetChars) {
return encoded;
if (encoded.dataUrl !== null) {
return { dataUrl: encoded.dataUrl, mimeType: encoded.mimeType };
}
}
return smallest;
return null;
}

type ReencodeResult =
Expand Down
8 changes: 7 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading