Skip to content

Commit 1780afc

Browse files
author
qwen-code-ci-bot
committed
fix(web-shell): gate the managed proof-of-life verdict expiry on an answered attempt
The proof-of-life timer expired the stream leg's standing verdict on wall time alone, so a reconnect that opened and went silent — a proxy black-holing the response body keeps the fetch in flight indefinitely — erased a terminal verdict or transient failure and left the panel rendering no alert over a dead stream. An open-but-silent connection is not the leg's success evidence: the expiry now fires only once the attempt has actually delivered a frame (a replay counts, since delivered is set before the replay guard), keeping the mirror capture and the catch-side restore unchanged. The transient expiry spec is restored to an answered reconnect (it had been rewritten to a quiet one, blessing the regression), and two new specs pin the unanswered polarity for both a terminal verdict and a transient failure; each goes red without the gate. Verification: managed suite 332/332; mutation probe (gate removed) reds exactly the two new specs; npm run build, typecheck, and lint clean.
1 parent 36d3898 commit 1780afc

2 files changed

Lines changed: 107 additions & 10 deletions

File tree

‎packages/web-shell/client/components/managed/use-managed-session.test.tsx‎

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,54 @@ describe('useManagedSession', () => {
685685
}
686686
});
687687

688+
it('keeps a terminal stream verdict standing through an unanswered reconnect', async () => {
689+
vi.useFakeTimers();
690+
const restoreBackoff = deterministicBackoff();
691+
try {
692+
let subscribeCalls = 0;
693+
const subscribeEvents = vi.fn(async function* (
694+
_sessionId: string,
695+
request: { signal?: AbortSignal },
696+
) {
697+
subscribeCalls += 1;
698+
if (subscribeCalls === 1) {
699+
yield event(1);
700+
throw Object.assign(new Error('session gone'), { status: 404 });
701+
}
702+
// An open-but-silent connection: it never yields, throws, or
703+
// returns — a proxy black-holing the response body.
704+
await new Promise((resolve) =>
705+
request.signal?.addEventListener('abort', resolve),
706+
);
707+
});
708+
const provider = {
709+
getSession: vi.fn().mockResolvedValue({ sessionId: 'session-1' }),
710+
getTranscript: vi.fn().mockResolvedValue(transcript(1)),
711+
subscribeEvents,
712+
} as unknown as ManagedAgentProvider;
713+
mountReact(<Probe provider={provider} />);
714+
await flushReact();
715+
await act(async () => {
716+
await vi.advanceTimersByTimeAsync(0);
717+
});
718+
expect(latest?.stoppedReason).toBe('session gone');
719+
expect(latest?.stoppedLeg).toBe('stream');
720+
// Attempt 2 opens at t=3000 and stays open past the +3s
721+
// proof-of-life point without ever answering: wall time alone is
722+
// not the leg's success evidence, so the verdict stands.
723+
await act(async () => {
724+
await vi.advanceTimersByTimeAsync(9_100);
725+
});
726+
expect(subscribeCalls).toBe(2);
727+
expect(latest?.stoppedReason).toBe('session gone');
728+
expect(latest?.stoppedLeg).toBe('stream');
729+
expect(latest?.error).toBeUndefined();
730+
} finally {
731+
restoreBackoff();
732+
vi.useRealTimers();
733+
}
734+
});
735+
688736
it('expires a transient stream failure on an error-free idle reconnect', async () => {
689737
vi.useFakeTimers();
690738
const restoreBackoff = deterministicBackoff();
@@ -699,8 +747,8 @@ describe('useManagedSession', () => {
699747
throw Object.assign(new Error('upstream unavailable'), {
700748
status: 502,
701749
});
702-
yield* [];
703-
// A live but idle session: the reconnect stays open and quiet.
750+
// A live but idle session: the reconnect replays history only.
751+
yield event(1);
704752
await new Promise((resolve) =>
705753
request.signal?.addEventListener('abort', resolve),
706754
);
@@ -730,6 +778,54 @@ describe('useManagedSession', () => {
730778
}
731779
});
732780

781+
it('keeps a transient stream failure standing through an unanswered reconnect', async () => {
782+
vi.useFakeTimers();
783+
const restoreBackoff = deterministicBackoff();
784+
try {
785+
let subscribeCalls = 0;
786+
const subscribeEvents = vi.fn(async function* (
787+
_sessionId: string,
788+
request: { signal?: AbortSignal },
789+
) {
790+
subscribeCalls += 1;
791+
if (subscribeCalls === 1)
792+
throw Object.assign(new Error('upstream unavailable'), {
793+
status: 502,
794+
});
795+
yield* [];
796+
// An open-but-silent connection: no frame ever arrives and the
797+
// attempt neither throws nor returns — a proxy black-holing the
798+
// response body.
799+
await new Promise((resolve) =>
800+
request.signal?.addEventListener('abort', resolve),
801+
);
802+
});
803+
const provider = {
804+
getSession: vi.fn().mockResolvedValue({ sessionId: 'session-1' }),
805+
getTranscript: vi.fn().mockResolvedValue(transcript(1)),
806+
subscribeEvents,
807+
} as unknown as ManagedAgentProvider;
808+
mountReact(<Probe provider={provider} />);
809+
await flushReact();
810+
await act(async () => {
811+
await vi.advanceTimersByTimeAsync(0);
812+
});
813+
expect(latest?.error).toBe('upstream unavailable');
814+
// Attempt 2 opens at t=3000 and stays open past the +3s
815+
// proof-of-life point without ever answering: the transient record
816+
// it never contradicted stays standing.
817+
await act(async () => {
818+
await vi.advanceTimersByTimeAsync(9_100);
819+
});
820+
expect(subscribeCalls).toBe(2);
821+
expect(latest?.error).toBe('upstream unavailable');
822+
expect(latest?.stoppedReason).toBeUndefined();
823+
} finally {
824+
restoreBackoff();
825+
vi.useRealTimers();
826+
}
827+
});
828+
733829
it('keeps the stall warning through an idle connection that never advances', async () => {
734830
vi.useFakeTimers();
735831
const restoreBackoff = deterministicBackoff();

‎packages/web-shell/client/components/managed/use-managed-session.ts‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -349,23 +349,24 @@ export function useManagedSession(
349349
let gap = false;
350350
let delayMs = 0;
351351
let delivered = false;
352-
// Set when the proof-of-life timer below expires a terminal verdict
353-
// while this attempt is still in flight: the removal stands only if
354-
// the attempt goes on to answer error-free, so a throw restores it.
352+
// Captured when the proof-of-life timer below fires: the expiry it
353+
// accompanies stands only if the attempt goes on to answer
354+
// error-free, so a throw restores the verdict.
355355
let expiredVerdictMessage: string | undefined;
356356
const connectedAt = Date.now();
357357
// The same duration that certifies a connection for the backoff
358358
// ladder below (a throw before it stretches the rung) also
359-
// certifies the stream leg itself: an answer that stays error-free
360-
// this long retires the leg's records — even when no new frame ever
361-
// arrives to retire them. A failed attempt is no such answer: the
362-
// catch below restores a verdict this removed.
359+
// certifies the stream leg itself — provided the attempt actually
360+
// answered. An open-but-silent connection is no answer, so the
361+
// expiry is gated on a delivered frame (a replay counts: delivered
362+
// is set before the replay guard). A failed attempt is no answer
363+
// either: the catch below restores a verdict this removed.
363364
const proofOfLife = setTimeout(() => {
364365
// Capture from the mirror synchronously: the state update runs
365366
// at React's flush, which an attempt failing right after the
366367
// expiry would beat to the catch.
367368
expiredVerdictMessage = streamVerdictMessageRef.current;
368-
expireAnswered('stream');
369+
if (delivered) expireAnswered('stream');
369370
}, BASE_RETRY_DELAY_MS);
370371
try {
371372
for await (const event of provider.subscribeEvents(sessionId, {

0 commit comments

Comments
 (0)