Skip to content

Commit 4d6defc

Browse files
committed
fix(agent-core-v2): retry a pending journal repair from flush
A repair that failed with no record appended afterwards left flush() reporting success over an unrepaired journal: nothing was queued, so there was no persisted error to surface. flush() now retries the pending repair when the append path has not already tried it in this cycle. Also cover the denied OpenAI Codex consent end to end through the flow, not only through the callback server.
1 parent baabd94 commit 4d6defc

3 files changed

Lines changed: 75 additions & 2 deletions

File tree

packages/agent-core-v2/src/wire/wireService.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,9 @@ export class WireService extends Service implements IWireService {
214214

215215
async flush(): Promise<void> {
216216
await this.persistQueue;
217+
if (this.pendingRepair !== undefined && this.persistError === undefined) {
218+
await this.repairPendingJournal().catch(() => undefined);
219+
}
217220
const persistError = this.persistError;
218221
this.persistError = undefined;
219222
if (persistError !== undefined) throw persistError;

packages/agent-core-v2/test/wire/wireService.test.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,47 @@ describe('WireService corruption repair', () => {
659659
]);
660660
});
661661

662+
it('retries a pending repair on flush even when nothing was appended', async () => {
663+
const capture: RepairCapture = { warnings: [], events: [] };
664+
const svc = wireWithCapture(KEY, capture);
665+
const prefix = `${currentMetadata()}\n`;
666+
const raw = `${prefix}GARBAGE\n`;
667+
await seedCorrupt(raw);
668+
const originalWrite = storage.write.bind(storage);
669+
storage.write = async (scope, key, data, options) => {
670+
if (key === AGENT_WIRE_RECORD_KEY) throw new Error('disk full');
671+
return originalWrite(scope, key, data, options);
672+
};
673+
await collect(svc.readJournal());
674+
storage.write = originalWrite;
675+
676+
await svc.flush();
677+
678+
expect(await rawBytes()).toBe(prefix);
679+
expect(await rawBytes(BACKUP_KEY)).toBe(raw);
680+
expect(capture.events).toMatchObject([
681+
{ name: 'wire_repair', payload: { outcome: 'failed' } },
682+
{ name: 'wire_repair', payload: { outcome: 'repaired' } },
683+
]);
684+
});
685+
686+
it('reports the still-broken journal from flush when nothing was appended', async () => {
687+
const capture: RepairCapture = { warnings: [], events: [] };
688+
const svc = wireWithCapture(KEY, capture);
689+
const prefix = `${currentMetadata()}\n`;
690+
const raw = `${prefix}GARBAGE\n`;
691+
await seedCorrupt(raw);
692+
const originalWrite = storage.write.bind(storage);
693+
storage.write = async (scope, key, data, options) => {
694+
if (key === AGENT_WIRE_RECORD_KEY) throw new Error('disk full');
695+
return originalWrite(scope, key, data, options);
696+
};
697+
await collect(svc.readJournal());
698+
699+
await expect(svc.flush()).rejects.toThrow('Wire journal repair did not complete');
700+
expect(await rawBytes()).toBe(raw);
701+
});
702+
662703
it('refuses to append behind the corrupted tail while a failed repair keeps failing', async () => {
663704
const capture: RepairCapture = { warnings: [], events: [] };
664705
const svc = wireWithCapture(KEY, capture);
@@ -681,7 +722,7 @@ describe('WireService corruption repair', () => {
681722
expect(await rawBytes()).toBe(raw);
682723
expect(unexpected).toHaveLength(1);
683724
expect(unexpected[0]).toBeInstanceOf(WireError);
684-
expect((unexpected[0] as WireError).code).toBe(WireErrors.codes.RECORDS_WRITE_FAILED);
725+
expect(unexpected[0]).toMatchObject({ code: WireErrors.codes.RECORDS_WRITE_FAILED });
685726
expect(capture.events).toEqual([
686727
{
687728
name: 'wire_repair',
@@ -729,7 +770,7 @@ describe('WireService corruption repair', () => {
729770
expect(await rawBytes()).toBe(raw);
730771
expect(unexpected).toHaveLength(1);
731772
expect(unexpected[0]).toBeInstanceOf(WireError);
732-
expect((unexpected[0] as WireError).code).toBe(WireErrors.codes.RECORDS_WRITE_FAILED);
773+
expect(unexpected[0]).toMatchObject({ code: WireErrors.codes.RECORDS_WRITE_FAILED });
733774
expect(capture.events).toEqual([
734775
{
735776
name: 'wire_repair',

packages/oauth/test/openai-codex-oauth.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
fetchOpenAICodexModels,
1111
OPENAI_CODEX_AUTH_INPUT_MAX_LENGTH,
1212
parseOpenAICodexAuthorizationInput,
13+
OAuthAccessDeniedError,
1314
OPENAI_CODEX_REDIRECT_URI,
15+
runOpenAICodexOAuthFlow,
1416
startOpenAICodexCallbackServer,
1517
type OpenAICodexConfigShape,
1618
} from '../src/openai-codex-oauth';
@@ -327,6 +329,33 @@ describe('startOpenAICodexCallbackServer', () => {
327329
}
328330
});
329331

332+
it('fails the whole flow with OAuthAccessDeniedError when the user denies consent', async () => {
333+
const probe = await startOpenAICodexCallbackServer('probe');
334+
const loopback = probe.loopback;
335+
probe.close();
336+
if (!loopback) return;
337+
338+
let authorizeUrl: string | undefined;
339+
const pending = runOpenAICodexOAuthFlow({
340+
timeoutMs: 10_000,
341+
openBrowser: (url) => {
342+
authorizeUrl = url;
343+
},
344+
});
345+
const settled = expect(pending).rejects.toBeInstanceOf(OAuthAccessDeniedError);
346+
347+
await vi.waitFor(() => expect(authorizeUrl).toBeDefined());
348+
const state = new URL(authorizeUrl!).searchParams.get('state');
349+
expect(state).toBeTruthy();
350+
351+
const url = new URL(OPENAI_CODEX_REDIRECT_URI);
352+
url.searchParams.set('error', 'access_denied');
353+
url.searchParams.set('state', state!);
354+
await fetch(url);
355+
356+
await settled;
357+
});
358+
330359
it('reports any other callback error as a dead end the user can still paste past', async () => {
331360
const server = await startOpenAICodexCallbackServer('state-abc');
332361
try {

0 commit comments

Comments
 (0)