Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ea4e495
fix(managed-agent): harden commit retry, worker containment, panel po…
wenshao Oct 1, 2026
4e085e6
fix(managed-agent): defer the plain-commit retry until fault-gate dri…
wenshao Oct 1, 2026
99c465f
Merge branch 'main' into fix/managed-agent-quality-hardening
wenshao Oct 2, 2026
3ad03d2
fix(web-shell): reset managed stream backoff on delivered events
wenshao Oct 2, 2026
24248ef
fix(web-shell): track managed stream, snapshot, and poller health sep…
wenshao Oct 2, 2026
3e84c17
fix(web-shell): keep managed streams alive across definite snapshot a…
wenshao Oct 2, 2026
ca150cb
fix(web-shell): render managed action errors beside the terminal stop
wenshao Oct 2, 2026
4e6a02a
Merge branch 'main' into fix/managed-agent-quality-hardening
wenshao Oct 3, 2026
eb6c54c
Merge remote-tracking branch 'origin/main' into fix/managed-agent-qua…
wenshao Oct 3, 2026
07a9756
chore(vscode-ide-companion): restore main's NOTICES.txt
wenshao Oct 3, 2026
e826f59
fix(web-shell): retire managed stream stops through their own authority
wenshao Oct 4, 2026
35495f2
fix(web-shell): record managed health signals per answer authority
wenshao Oct 4, 2026
c07ebea
fix(web-shell): let the managed stream retire its own verdict on deli…
wenshao Oct 4, 2026
fec5222
fix(web-shell): guard managed verdict finality and clear loading on e…
wenshao Oct 5, 2026
9439da7
fix(web-shell): let every managed read authority retire its own verdi…
wenshao Oct 5, 2026
9eecbc5
fix(web-shell): retire managed verdicts on any answered read and rest…
qwen-code-ci-bot Oct 5, 2026
36d3898
Merge branch 'main' into fix/managed-agent-quality-hardening
qwen-code-dev-bot Oct 5, 2026
1780afc
fix(web-shell): gate the managed proof-of-life verdict expiry on an a…
qwen-code-ci-bot Oct 5, 2026
ae0f9f8
Merge branch 'main' into fix/managed-agent-quality-hardening
Oct 6, 2026
f82bab1
fix(web-shell): expire stream verdicts on heartbeat-answered idle rec…
Oct 6, 2026
2061be7
Merge branch 'main' into fix/managed-agent-quality-hardening
qwen-code-dev-bot Oct 6, 2026
264823f
Merge remote-tracking branch 'origin/main' into fix/managed-agent-qua…
wenshao Oct 6, 2026
d8cf0a0
fix(web-shell): never resurrect a heartbeat-expired stream verdict (#…
Oct 7, 2026
99dea2d
fix(web-shell): tighten the signal-leg ledger and alert de-duplicatio…
Oct 7, 2026
c25755d
fix(web-shell): land the proof-of-life expiry on late frames and boun…
Oct 7, 2026
79d77b2
fix(web-shell): keep the spent re-arm bound's verdict standing (#13179)
Oct 8, 2026
040c402
fix(web-shell): recover a spent re-arm bound on the slowest rung (#13…
Oct 10, 2026
774bba8
Merge branch 'main' into fix/managed-agent-quality-hardening
qwen-code-dev-bot Oct 10, 2026
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
Prev Previous commit
Next Next commit
fix(web-shell): reset managed stream backoff on delivered events
When an SSE connection dies by proxy idle timeout it surfaces as a
status-less throw, so the stream loop's end-of-iteration reset never ran
and the shared failure counter climbed to the 30s cap for the life of
the panel even though every reconnect succeeded and delivered events.
Reset on progress inside the for-await body, matching the bootstrap and
summary loops' notion of success.

Also pin previously unwitnessed review invariants: the empty-incoming
merge case (a load-bearing crash guard, not an optimization), the exact
exponential backoff ladder under a deterministic random, the stream
loop's non-retryable exit and its unchanged resubscribe cursor, and
comments documenting the exact-3s first-failure floor and the merge
fast path's sorted-input contract.

Co-authored-by: Qwen-Coder <[email protected]>
  • Loading branch information
wenshao and qwencoder committed Oct 2, 2026
commit 3ad03d2ea17e57991110fdb48c19bf5c94a6b05e
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,15 @@ describe('Managed transcript projection', () => {
expect(merged[2]?.data).toEqual({ text: 'newer' });
});

it('returns the current events unchanged for an empty incoming page', () => {
expect(
mergeManagedEvents([event(1, 'accepted'), event(2, 'completed')], []).map(
(item) => item.id,
),
).toEqual([1, 2]);
expect(mergeManagedEvents([], []).map((item) => item.id)).toEqual([]);
});

it('keeps approval updates out of the Turn being streamed', () => {
const messages = managedEventsToMessages(
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ import type { ACPToolCall, Message } from '../../adapters/types';
import type { ManagedAgentSessionEvent } from './managed-agent-provider';
import type { ManagedToolResult } from './managed-tool-result-types';

// `current` must already be ascending and deduplicated by id (stream state
// and transcript pages are); the append path checks only that `incoming`
// extends it, so a disordered first argument stays disordered.
export function mergeManagedEvents(
current: readonly ManagedAgentSessionEvent[],
incoming: readonly ManagedAgentSessionEvent[],
): ManagedAgentSessionEvent[] {
// An empty input cannot ride the ascending check: strictlyAscendingIds
// loops from index 1, and the fast path dereferences incoming[0].
if (incoming.length === 0) return [...current];
Comment thread
wenshao marked this conversation as resolved.
if (
strictlyAscendingIds(incoming) &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,129 @@ describe('useManagedSession', () => {
});
});

it('grows the failure backoff exponentially toward the jittered cap', () => {
const random = vi.spyOn(Math, 'random').mockReturnValue(0.5);
try {
expect([0, 1, 2, 3, 4, 5, 6].map(failureRetryDelayMs)).toEqual([
3_000, 4_500, 7_500, 13_500, 16_500, 16_500, 16_500,
]);
} finally {
random.mockRestore();
}
});

it('resets the stream backoff after every delivered event', async () => {
vi.useFakeTimers();
const random = vi.spyOn(Math, 'random').mockReturnValue(0.999999);
try {
let calls = 0;
const subscribeEvents = vi.fn(async function* (
_sessionId: string,
request: { signal?: AbortSignal },
) {
calls++;
if (calls <= 3) {
yield event(calls);
throw new TypeError('connection reset by peer');
}
yield event(4);
await new Promise((resolve) =>
request.signal?.addEventListener('abort', resolve),
);
});
const provider = {
getSession: vi.fn().mockResolvedValue({ sessionId: 'session-1' }),
getTranscript: vi.fn().mockResolvedValue(transcript(1)),
subscribeEvents,
} as unknown as ManagedAgentProvider;
function Probe() {
useManagedSession(provider, 'client-1', 'session-1');
return null;
}
root = createRoot(document.createElement('div'));
act(() => root!.render(<Probe />));
await act(async () => {});
for (let step = 0; step < 3; step++)
await act(async () => {
await vi.advanceTimersByTimeAsync(3_000);
});
expect(
subscribeEvents.mock.calls.map(([, options]) => options.lastEventId),
Comment thread
wenshao marked this conversation as resolved.
).toEqual([1, 1, 2, 3]);
} finally {
random.mockRestore();
vi.useRealTimers();
}
});

it('stops resubscribing when the stream answers with a non-retryable error', async () => {
vi.useFakeTimers();
try {
const subscribeEvents = vi.fn(async function* () {
yield event(1);
throw Object.assign(new Error('session gone'), { status: 404 });
});
const provider = {
getSession: vi.fn().mockResolvedValue({ sessionId: 'session-1' }),
getTranscript: vi.fn().mockResolvedValue(transcript(1)),
subscribeEvents,
} as unknown as ManagedAgentProvider;
function Probe() {
useManagedSession(provider, 'client-1', 'session-1');
return null;
}
root = createRoot(document.createElement('div'));
act(() => root!.render(<Probe />));
await act(async () => {});
expect(subscribeEvents).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(120_000);
});
expect(subscribeEvents).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});

it('resubscribes an unchanged cursor after a retryable stream failure', async () => {
vi.useFakeTimers();
try {
let calls = 0;
const subscribeEvents = vi.fn(async function* (
_sessionId: string,
request: { signal?: AbortSignal },
) {
calls++;
if (calls === 1)
throw Object.assign(new Error('server busy'), { status: 500 });
yield event(2);
await new Promise((resolve) =>
request.signal?.addEventListener('abort', resolve),
);
});
const provider = {
getSession: vi.fn().mockResolvedValue({ sessionId: 'session-1' }),
getTranscript: vi.fn().mockResolvedValue(transcript(1)),
subscribeEvents,
} as unknown as ManagedAgentProvider;
function Probe() {
useManagedSession(provider, 'client-1', 'session-1');
return null;
}
root = createRoot(document.createElement('div'));
act(() => root!.render(<Probe />));
await act(async () => {});
expect(subscribeEvents).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(3_000);
});
expect(subscribeEvents).toHaveBeenCalledTimes(2);
expect(subscribeEvents.mock.calls[1]?.[1].lastEventId).toBe(1);
} finally {
vi.useRealTimers();
}
});

it('stops the bootstrap retry loop on a non-retryable failure', async () => {
vi.useFakeTimers();
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { mergeManagedEvents } from './managed-session-messages';
const BASE_RETRY_DELAY_MS = 3_000;
const MAX_RETRY_DELAY_MS = 30_000;

// Rung zero must stay exactly BASE_RETRY_DELAY_MS: ManagedSessionsPage pins
// the gap-recovery cadence with a 2999/3000ms boundary in another file, and
// any first-failure jitter would break it.
export function failureRetryDelayMs(failures: number): number {
Comment thread
wenshao marked this conversation as resolved.
const cap = Math.min(MAX_RETRY_DELAY_MS, BASE_RETRY_DELAY_MS * 2 ** failures);
return (
Expand Down Expand Up @@ -116,6 +119,9 @@ export function useManagedSession(
}
if (event.id <= lastEventId) continue;
lastEventId = event.id;
// A delivered event proves the connection healthy even when it
// later dies by proxy idle timeout, which surfaces as a throw.
failures = 0;
Comment thread
wenshao marked this conversation as resolved.
Outdated
setState((current) => ({
...current,
events: mergeManagedEvents(current.events, [event]),
Expand Down
Loading