Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
7695026
test: wait for state instead of fixed timing in three flaky tests
yiliang114 Sep 2, 2026
40eff54
fix(release): retry a flaky workspace test instead of failing the rel…
yiliang114 Sep 2, 2026
8f63b43
fix(acp): keep a failing late-drain recovery from ending the process
yiliang114 Sep 2, 2026
6ef630d
fix(release): let the surviving-hook test's descendant outlive the de…
yiliang114 Sep 2, 2026
3526104
fix(deps): update vulnerable production packages
yiliang114 Sep 2, 2026
3c5e95c
chore(vscode-ide-companion): regenerate NOTICES.txt for bumped deps
yiliang114 Sep 2, 2026
53eebfa
test(release): align widened waits with per-test budgets and lane-awa…
yiliang114 Sep 2, 2026
d6ebe03
docs(test): correct two comments that now describe the opposite of th…
yiliang114 Sep 2, 2026
bb72ad3
test(ci): give the autofix workflow suite headroom over host contention
Sep 3, 2026
cd0b91c
test(cli): pin the late drain recovery guard against unhandled reject…
yiliang114 Sep 3, 2026
8351379
Merge branch 'main' into fix/flaky-release-tests
yiliang114 Sep 3, 2026
3bf8283
test(core): size the surviving-hook timeout fixture for loaded startup
yiliang114 Sep 3, 2026
329e134
fix(ci): isolate Maven config per SDK Java job
yiliang114 Sep 3, 2026
f20fcc1
Merge remote updates into CI fix
yiliang114 Sep 3, 2026
a4729e9
test(serve): wait for the shared session/list waiter before cutting a…
yiliang114 Sep 3, 2026
db1069c
test(serve): skip an interface that vanished mid-suite instead of fai…
yiliang114 Sep 3, 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
24 changes: 16 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -540,16 +540,24 @@ jobs:
VITEST_MIN_THREADS: "${{ startsWith(runner.name, 'ecs-qwen-') && '1' || '' }}"
VITEST_MAX_FORKS: "${{ startsWith(runner.name, 'ecs-qwen-') && (vars.QWEN_CI_VITEST_MAX_WORKERS || '4') || '' }}"
VITEST_MIN_FORKS: "${{ startsWith(runner.name, 'ecs-qwen-') && '1' || '' }}"
# Nightly and preview releases may absorb a transient timing
# failure. Stable releases add no retry of their own and stay on
# whatever each workspace's Vitest config asks for; the flag is
# omitted rather than set to 0, because a command-line --retry=0
# outranks the config and would disable a workspace's deliberate
# retry (packages/sdk-typescript) on the release lane alone.
VITEST_RETRY: "${{ (needs.prepare.outputs.is_nightly == 'true' || needs.prepare.outputs.is_preview == 'true') && '2' || '' }}"
# Every release schedule absorbs a transient timing failure, not
# just nightly and preview. A stable release used to run with no
# retry at all, so any one flaky test out of ~30k reddened it
# outright: six consecutive stable runs failed on a single test
# each while every other gate stayed green, and the same test
# passed on the next attempt. Retrying hides nothing — a real
# break fails all three attempts — it only stops a contended
# runner from deciding whether a release ships. Operators can
# retune this without a PR via the repository variable, or set it
# to 'off' to omit the flag entirely. 'off' rather than '0': a
# command-line --retry=0 outranks the config and would disable a
# workspace's deliberate retry (packages/sdk-typescript) on the
# release lane alone, and an empty variable just falls back to the
# default.
VITEST_RETRY: "${{ vars.QWEN_RELEASE_VITEST_RETRY || '2' }}"
run: |-
retry_arg=()
if [ -n "${VITEST_RETRY}" ]; then
if [ -n "${VITEST_RETRY}" ] && [ "${VITEST_RETRY}" != 'off' ]; then
retry_arg=("--retry=${VITEST_RETRY}")
fi
npm run test:release:workspaces -- --shard=${{ matrix.shard }}/3 --passWithNoTests "${retry_arg[@]}"
Expand Down
31 changes: 14 additions & 17 deletions .github/workflows/sdk-java.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,24 +98,16 @@ jobs:
exit 1
fi

# Runner instances on one self-hosted machine share $HOME and therefore
# ~/.m2/toolchains.xml. setup-java merges its JDK entry into that file
# with a non-atomic read-modify-write, so two concurrent jobs can tear
# it — and once torn, every later job on the machine fails Set up Java
# with "Cannot insert a text node as a child of a document node". The
# build never reads toolchains.xml (no maven-toolchains-plugin), so
# dropping it is free and setup-java rewrites it from scratch.
- name: 'Drop shared Maven toolchains.xml (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
run: 'rm -f "${HOME}/.m2/toolchains.xml"'

# setup-java updates Maven files non-atomically. Keep them job-local
# because runner instances on one ECS host share HOME.
- name: 'Set up Java'
uses: 'actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654' # v5.2.0
with:
distribution: 'temurin'
java-version: '${{ matrix.java }}'
cache: 'maven'
cache-dependency-path: 'packages/sdk-java/qwencode/pom.xml'
settings-path: '${{ runner.temp }}/setup-java-m2'

- name: 'Set up Maven (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
Expand All @@ -129,6 +121,8 @@ jobs:
- name: 'Run Java SDK tests (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
working-directory: 'packages/sdk-java/qwencode'
env:
MAVEN_ARGS: '--settings ${{ runner.temp }}/setup-java-m2/settings.xml --toolchains ${{ runner.temp }}/setup-java-m2/toolchains.xml'
run: |-
mkdir -p "${HOME}/.cache/qwen-code-ci"
exec 9>"${HOME}/.cache/qwen-code-ci/sdk-java-tests.lock"
Expand All @@ -141,16 +135,22 @@ jobs:
- name: 'Run Java SDK tests (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
working-directory: 'packages/sdk-java/qwencode'
env:
MAVEN_ARGS: '--settings ${{ runner.temp }}/setup-java-m2/settings.xml --toolchains ${{ runner.temp }}/setup-java-m2/toolchains.xml'
run: 'mvn --batch-mode --no-transfer-progress clean test'

- name: 'Run Java SDK Checkstyle'
if: "${{ matrix.os == 'ubuntu-latest' && matrix.java == '11' }}"
working-directory: 'packages/sdk-java/qwencode'
env:
MAVEN_ARGS: '--settings ${{ runner.temp }}/setup-java-m2/settings.xml --toolchains ${{ runner.temp }}/setup-java-m2/toolchains.xml'
run: 'mvn --batch-mode --no-transfer-progress checkstyle:check'

- name: 'Build release artifacts'
if: "${{ matrix.os == 'ubuntu-latest' && matrix.java == '11' }}"
working-directory: 'packages/sdk-java/qwencode'
env:
MAVEN_ARGS: '--settings ${{ runner.temp }}/setup-java-m2/settings.xml --toolchains ${{ runner.temp }}/setup-java-m2/toolchains.xml'
run: 'mvn --batch-mode --no-transfer-progress -DskipTests -Dgpg.skip=true package'

daemon-e2e:
Expand Down Expand Up @@ -205,19 +205,14 @@ jobs:
echo "::warning::Expected Node 22.x but found $(node -v); daemon E2E will run against the runner's Node."
fi

# Same shared-$HOME hazard as the unit job above: drop the torn-prone
# toolchains.xml so a corrupt leftover cannot fail Set up Java.
- name: 'Drop shared Maven toolchains.xml (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
run: 'rm -f "${HOME}/.m2/toolchains.xml"'

- name: 'Set up Java'
uses: 'actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654' # v5.2.0
with:
distribution: 'temurin'
java-version: '11'
cache: 'maven'
cache-dependency-path: 'packages/sdk-java/qwencode/pom.xml'
settings-path: '${{ runner.temp }}/setup-java-m2'

- name: 'Set up Maven (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
Expand All @@ -238,4 +233,6 @@ jobs:
run: 'npm run bundle'

- name: 'Run Java daemon E2E'
env:
MAVEN_ARGS: '--settings ${{ runner.temp }}/setup-java-m2/settings.xml --toolchains ${{ runner.temp }}/setup-java-m2/toolchains.xml'
run: 'npx tsx scripts/run-java-daemon-sdk-e2e.ts'
27 changes: 14 additions & 13 deletions package-lock.json

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

94 changes: 94 additions & 0 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18320,6 +18320,100 @@ describe('Session', () => {
).toHaveBeenCalledWith([midTurnPart], 'please also check tests');
}, 20_000);

it('keeps a logger failure inside late drain recovery from escaping', async () => {
// Regression for the timeout branch's `.catch(() => {})` guard: the
// recovery is best-effort by construction, and anything thrown after
// the drain race — the debug logger among them — would escape a bare
// `void` as an unhandled rejection and end the daemon process. Same
// shape as the recovery test above, but the module-graph logger mock
// faults exactly on the recovery's own debug line, and the run must
// surface zero escaped rejections (vitest fails a file on one).
const tool = {
name: 'read_file',
kind: core.Kind.Read,
build: vi.fn().mockReturnValue({
params: { path: '/tmp/test.txt' },
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
getDescription: vi.fn().mockReturnValue('Read file'),
toolLocations: vi.fn().mockReturnValue([]),
execute: vi
.fn()
.mockResolvedValue({ llmContent: 'ok', returnDisplay: 'ok' }),
}),
};
mockToolRegistry.getTool.mockReturnValue(tool);
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);

let resolveLate: (value: { messages: string[] }) => void = () => {};
const latePromise = new Promise<{ messages: string[] }>((res) => {
resolveLate = res;
});
let drainCalls = 0;
mockClient.extMethod = vi.fn((method: string) => {
if (method !== 'craft/drainMidTurnQueue') return Promise.resolve({});
drainCalls += 1;
return drainCalls === 1
? latePromise
: Promise.resolve({ messages: [] });
});

mockChat.sendMessageStream = vi
.fn()
.mockResolvedValueOnce(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'c',
name: 'read_file',
args: { path: '/tmp/test.txt' },
},
],
},
},
]),
)
.mockResolvedValue(createEmptyStream());

// Fault through the module graph (sessionIdContext.run in a real turn
// bypasses any process-wide logger session): the recovery's own debug
// line throws, everything else logs normally.
debugLoggerDebugSpy.mockImplementation((message: unknown) => {
if (
typeof message === 'string' &&
message.includes('timed-out drain')
) {
throw new Error('debug logger unavailable');
}
});

const unhandled = vi.fn();
process.on('unhandledRejection', unhandled);
try {
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text' as const, text: 'read file' }],
});

// The daemon's late answer lands; flush so the recovery race
// settles and its throwing logger call runs inside this test.
resolveLate({ messages: ['late message'] });
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setImmediate(r));

// The recovery did reach its debug line (the fault actually fired).
expect(debugLoggerDebugSpy).toHaveBeenCalledWith(
expect.stringContaining('timed-out drain'),
);
expect(unhandled).not.toHaveBeenCalled();
} finally {
process.off('unhandledRejection', unhandled);
debugLoggerDebugSpy.mockImplementation(() => {});
}
}, 20_000);

it('keeps mid-turn drain enabled after a transient error', async () => {
const tool = {
name: 'read_file',
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8301,8 +8301,15 @@ export class Session implements SessionContext {
// for. Recover that late response and inject it on the next batch instead
// of discarding it (which would lose the messages from both queues —
// silent loss). `#recoverLateDrain` bounds the wait and swallows a late
// rejection.
if (drainPromise) void this.#recoverLateDrain(drainPromise);
// rejection, but only of the drain promise: anything that throws after
// that race — the debug logger among them — escapes a bare `void` as an
// unhandled rejection, which ends the process. This recovery is
// best-effort by construction, so nothing it does may take the session
// down with it. Swallow silently rather than log, since the logger is
// itself one of the things that can throw here.
if (drainPromise) {
void this.#recoverLateDrain(drainPromise).catch(() => {});
Comment thread
yiliang114 marked this conversation as resolved.
}
}
// Repeated timeouts are also permanent: a conforming client answers
// (or rejects with -32601) immediately, so sustained silence means the
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/src/serve/acp-http/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ import {
type WorkspaceRuntime,
} from '../workspace-registry.js';
import { SessionArchiveCoordinator } from '../server/session-archive.js';
import {
PersistedSessionListCache,
type PersistedSessionListCacheStatus,
} from '../server/persisted-session-list-cache.js';
import { createRequestedSessionIdAdmission } from '../session-id-admission.js';
import { CredentialStore } from '../local-control/credentials.js';
import { tagListener } from '../local-control/listener-identity.js';
Expand Down Expand Up @@ -3693,6 +3697,27 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
});
return scan;
});
// The scan is cancelled the moment its waiter count reaches zero, and a
// request only becomes a waiter once it reaches `lookup()`. Posting both
// requests and waiting for the loader proves only that the *first* one
// attached: on a loaded runner the second can still be resolving params
// when the DELETE below tears the first connection down, which drops the
// count to zero, aborts the shared scan, and leaves the second request
// answering from a rejected promise. Watch `lookup()` for the
// single-flight join so the DELETE never lands before the second waiter
// is attached.
const lookupStatuses: PersistedSessionListCacheStatus[] = [];
const originalLookup = PersistedSessionListCache.prototype.lookup;
const lookupSpy = vi
.spyOn(PersistedSessionListCache.prototype, 'lookup')
.mockImplementation(function (
this: PersistedSessionListCache,
...args: Parameters<typeof originalLookup>
) {
const lookup = originalLookup.apply(this, args);
lookupStatuses.push(lookup.status);
return lookup;
});

try {
const firstConnId = await initialize();
Expand All @@ -3715,6 +3740,12 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
]);
await waitUntil(() => loadSignal !== undefined);
expect(listSessionsSpy).toHaveBeenCalledTimes(1);
await waitUntil(
() =>
lookupStatuses.filter((status) => status === 'single_flight')
.length === 1,
10_000,
);

const deleted = await fetch(`${base}/acp`, {
method: 'DELETE',
Expand All @@ -3732,6 +3763,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
]);
} finally {
resolveScan({ items: [], nextCursor: undefined, hasMore: false });
lookupSpy.mockRestore();
listSessionsSpy.mockRestore();
}
});
Expand Down
Loading
Loading