Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
161c784
fix(test): wait for interactive PTY sessions to end during cleanup (#…
qwen-code-dev-bot Sep 4, 2026
4b9222f
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 4, 2026
01b7633
test(integration): shrink the cleanup grace and warn when it expires …
qwen-code-dev-bot Sep 4, 2026
4d8a642
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 4, 2026
2e6c42e
test(integration): pin the exit grace to literal bounds (#10990)
qwen-code-dev-bot Sep 4, 2026
0b3f089
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 4, 2026
55aa9d4
test(scripts): pin the prompt-latency spec in the no-AK script list
qwen-code-dev-bot Sep 4, 2026
f02543b
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 5, 2026
9b34a6e
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 5, 2026
38fb1ad
fix(test): wait for the whole interactive PTY session in rig cleanup …
qwen-code-dev-bot Sep 5, 2026
6ff44c4
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 5, 2026
605b0a3
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 5, 2026
4e37d2e
test(integration): drop the contradicted #10990 attribution from clea…
qwen-code-dev-bot Sep 5, 2026
2897955
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 6, 2026
fc8801c
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 6, 2026
b22948e
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 7, 2026
654835a
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 7, 2026
8d89d4d
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 7, 2026
7b43e33
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 7, 2026
8269a66
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 8, 2026
062eb23
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 8, 2026
d1648f8
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 8, 2026
eb92aa8
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 8, 2026
69878a2
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 8, 2026
2a5e729
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 8, 2026
a6a77c9
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 9, 2026
68fbc1d
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 9, 2026
edaf7e1
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 9, 2026
2775328
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 10, 2026
1a28655
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 10, 2026
c19a32d
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 11, 2026
646b726
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 11, 2026
c7ed49d
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 12, 2026
cac0a60
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 13, 2026
78b1c47
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 14, 2026
0d5f53b
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 15, 2026
a5ec379
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 16, 2026
080ebb5
Merge branch 'main' into autofix/issue-10990
qwen-code-dev-bot Sep 17, 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
179 changes: 156 additions & 23 deletions integration-tests/test-helper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { existsSync } from 'node:fs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TestRig } from './test-helper.js';
import { INTERACTIVE_EXIT_GRACE_MS, TestRig } from './test-helper.js';

function isProcessAlive(pid: number): boolean {
try {
Expand All @@ -17,6 +17,19 @@ function isProcessAlive(pid: number): boolean {
}
}

// How long the stand-in below stays alive after it is signalled. The real CLI
// traps SIGHUP and exits only once its own exit-cleanup chain has drained, so
// a stand-in that dies on the default action would let cleanup() return early
// and still look correct.
const STAND_IN_EXIT_DELAY_MS = 750;

// Every interactive case below stands in for the CLI through rig.bundlePath.
// The installed-release lane spawns the installed CLI instead and never runs a
// stand-in, so these cases cannot measure that lane directly — the wrapper
// stand-in below reproduces its process topology on the bundle lane instead.
const usesInstalledCli =
process.env['INTEGRATION_TEST_USE_INSTALLED_GEMINI'] === 'true';

describe('TestRig', () => {
const originalKeepOutput = process.env['KEEP_OUTPUT'];

Expand Down Expand Up @@ -63,31 +76,151 @@ describe('TestRig', () => {
expect(existsSync(testDir)).toBe(true);
});

it('kills an interactive session a test never closed during cleanup', async () => {
// KEEP_OUTPUT is what CI sets, and it makes cleanup() keep the test
// directory — the spawned child must not survive that path either.
process.env['KEEP_OUTPUT'] = 'true';
const rig = new TestRig();
await rig.setup('cleanup kills interactive session');
// Stands in for the CLI bundle: what is under test is that cleanup ends
// whatever runInteractive spawned, not what the CLI itself does.
rig.bundlePath = rig.createFile(
'idle-cli.js',
'setInterval(() => {}, 1000);\n',
);
it.skipIf(usesInstalledCli)(
'waits for an interactive session a test never closed to end',
async () => {
// KEEP_OUTPUT is what CI sets, and it makes cleanup() keep the test
// directory — the spawned child must not survive that path either.
process.env['KEEP_OUTPUT'] = 'true';
const rig = new TestRig();
await rig.setup('cleanup kills interactive session');
// Stands in for the CLI bundle: what is under test is that cleanup ends
// whatever runInteractive spawned, not what the CLI itself does.
rig.bundlePath = rig.createFile(
'slow-exit-cli.js',
'process.on("SIGHUP", () => setTimeout(() => process.exit(129), ' +
`${STAND_IN_EXIT_DELAY_MS}));\n` +
'setInterval(() => {}, 1000);\n' +
'process.stdout.write("STAND_IN_READY\\n");\n',
);

const { ptyProcess } = rig.runInteractive();
expect(isProcessAlive(ptyProcess.pid)).toBe(true);
const { ptyProcess } = rig.runInteractive();
expect(isProcessAlive(ptyProcess.pid)).toBe(true);
// Signal before the handler is installed and the default action ends the
// child at once, measuring nothing. A real session is booted by the time
// its test ends, so wait for the stand-in to report itself up.
expect(await rig.waitForText('STAND_IN_READY', 30_000)).toBe(true);

await rig.cleanup();
const cleanupStartedAt = Date.now();
await rig.cleanup();
const cleanupTookMs = Date.now() - cleanupStartedAt;

await expect
.poll(() => isProcessAlive(ptyProcess.pid), {
message: 'the interactive CLI child outlived cleanup()',
timeout: 10_000,
})
.toBe(false);
});
// Signalling alone returns straight through the delay above, leaving the
// child forwarding PTY bytes into a worker vitest is tearing down.
expect(
cleanupTookMs,
'cleanup() returned before the interactive CLI child exited',
).toBeGreaterThanOrEqual(STAND_IN_EXIT_DELAY_MS);
// Nor may it fall through the whole grace, which is what cleanup() does
// when `exited` never settles — i.e. when the onExit wiring breaks. The
// bound sits far above the stand-in's exit delay, far below the grace.
expect(cleanupTookMs).toBeLessThan(5_000);
await expect
.poll(() => isProcessAlive(ptyProcess.pid), {
message: 'the interactive CLI child outlived cleanup()',
timeout: 10_000,
})
.toBe(false);
},
);

it.skipIf(usesInstalledCli)(
'waits for the CLI the installed bin wrapper relaunched to end',
async () => {
process.env['KEEP_OUTPUT'] = 'true';
const rig = new TestRig();
await rig.setup('cleanup waits past the bin wrapper');
// Mimics the installed-release lane, where the bin wrapper node-pty
// spawns relaunches the real CLI with spawnSync and installs no signal
// handler: SIGHUP ends the wrapper at once, while the relaunched CLI
// traps it and keeps draining. That CLI, not the wrapper, is what
// cleanup() must not return early on.
const relaunched = rig.createFile(
'relaunched-cli.cjs',
'process.on("SIGHUP", () => setTimeout(() => process.exit(129), ' +
`${STAND_IN_EXIT_DELAY_MS}));\n` +
'setInterval(() => {}, 1000);\n' +
'process.stdout.write("RELAUNCHED_PID=" + process.pid + ' +
'"\\nRELAUNCHED_READY\\n");\n',
);
rig.bundlePath = rig.createFile(
'bin-wrapper.cjs',
"const { spawnSync } = require('node:child_process');\n" +
`const result = spawnSync(process.execPath, [${JSON.stringify(
relaunched,
)}], {\n` +
" stdio: 'inherit',\n" +
'});\n' +
'if (result.signal) process.kill(process.pid, result.signal);\n' +
'else process.exit(result.status ?? 1);\n',
);

const { ptyProcess } = rig.runInteractive();
expect(await rig.waitForText('RELAUNCHED_READY', 30_000)).toBe(true);
const reported = /RELAUNCHED_PID=(\d+)/.exec(rig._interactiveOutput);
expect(
reported,
'the relaunched CLI never reported its pid',
).not.toBeNull();
const relaunchedPid = Number(reported![1]);
expect(isProcessAlive(relaunchedPid)).toBe(true);

const cleanupStartedAt = Date.now();
await rig.cleanup();

// The wrapper dies on SIGHUP's default action within milliseconds, so
// only a wait that sees past it can still be running here.
expect(Date.now() - cleanupStartedAt).toBeGreaterThanOrEqual(
STAND_IN_EXIT_DELAY_MS,
);
expect(
isProcessAlive(relaunchedPid),
'cleanup() returned while the relaunched CLI was still draining',
).toBe(false);
expect(isProcessAlive(ptyProcess.pid)).toBe(false);
},
);

it.skipIf(usesInstalledCli)(
'stops waiting for an interactive child that never exits',
async () => {
process.env['KEEP_OUTPUT'] = 'true';
const rig = new TestRig();
await rig.setup('cleanup gives up on a child that never exits');
rig.bundlePath = rig.createFile(
'never-exit-cli.js',
'process.on("SIGHUP", () => {});\n' +
'setInterval(() => {}, 1000);\n' +
'process.stdout.write("STAND_IN_READY\\n");\n',
);

const { ptyProcess } = rig.runInteractive();
expect(await rig.waitForText('STAND_IN_READY', 30_000)).toBe(true);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

const cleanupStartedAt = Date.now();
try {
await rig.cleanup();
} finally {
// cleanup() gave up on this child, so the rig no longer tracks it.
ptyProcess.kill('SIGKILL');
}
const cleanupTookMs = Date.now() - cleanupStartedAt;

// Literals, not the constant: a bound derived from it retunes with the
// value it polices. Floor is the CLI's 5s exit-cleanup chain, ceiling
// vitest's 10s default hookTimeout — see INTERACTIVE_EXIT_GRACE_MS.
expect(INTERACTIVE_EXIT_GRACE_MS).toBeGreaterThan(5_000);
expect(INTERACTIVE_EXIT_GRACE_MS).toBeLessThan(10_000);
// Bounded, or a child that ignores SIGHUP hangs teardown forever.
expect(cleanupTookMs).toBeLessThan(INTERACTIVE_EXIT_GRACE_MS + 5_000);
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
// Giving up has to name the child it abandoned, or the EPIPE crash this
// wait exists to prevent returns with nothing pointing back at it.
expect(warn.mock.calls.flat().join(' ')).toContain(
String(ptyProcess.pid),
);
},
);

it.each([
[
Expand Down
83 changes: 79 additions & 4 deletions integration-tests/test-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,62 @@ export function validateModelOutput(
return true;
}

// The CLI traps SIGHUP and exits only once `runExitCleanup()` has drained, a
// chain it bounds at 5s (packages/cli/src/utils/cleanup.ts), so the grace has
// to outlast that. It also has to stay inside the 10s hookTimeout vitest
// defaults to, because cleanup() runs in afterEach hooks: a grace that eats
// the whole hook budget is reported as a generic "Hook timed out" instead of
// naming the child that never exited.
export const INTERACTIVE_EXIT_GRACE_MS = 8_000;

// `process.kill(-pid, 0)` reports whether anything is left in the process
// group `pid` leads; it throws once the group is empty. Windows has no
// negative-pid process groups and always throws, so there the child's own exit
// stays the whole bound.
function sessionAlive(pid: number): boolean {
try {
process.kill(-pid, 0);
return true;
} catch {
return false;
}
}

// Resolves true once the whole PTY session is gone, false when `ms` elapses
// first. `exited` covers only the process node-pty spawned, and node-pty
// signals only that pid — on the installed-release lane it is the bin wrapper,
// which relaunches the real CLI with spawnSync and dies on SIGHUP's default
// action at once, so `exited` settles while that CLI is still draining into
// the PTY. The spawned process leads its own process group and the
// relaunched CLI stays in it, and a group reads empty only once every member
// has exited and been reaped, so wait for both inside the same grace. Timers
// are unrefed so a lost race leaves no handle holding the worker's event loop
// open.
function sessionEndsWithin(
pid: number,
exited: Promise<unknown>,
ms: number,
): Promise<boolean> {
return new Promise((resolve) => {
const deadline = Date.now() + ms;
let childExited = false;
const check = () => {
const gone = childExited && !sessionAlive(pid);
if (gone || Date.now() >= deadline) {
clearInterval(timer);
resolve(gone);
}
};
const onChildExit = () => {
childExited = true;
check();
};
const timer = setInterval(check, 50);
timer.unref();
void exited.then(onChildExit, onChildExit);
});
}

// Simulates typing a string one character at a time to avoid paste detection.
export async function type(ptyProcess: pty.IPty, text: string) {
const delay = 5;
Expand Down Expand Up @@ -205,7 +261,10 @@ export class TestRig {
testName?: string;
_lastRunStdout?: string;
_interactiveOutput = '';
private readonly interactiveProcesses: pty.IPty[] = [];
private readonly interactiveProcesses: Array<{
ptyProcess: pty.IPty;
exited: Promise<unknown>;
}> = [];

constructor() {
this.bundlePath = join(__dirname, '..', 'dist/cli.js');
Expand Down Expand Up @@ -512,13 +571,26 @@ export class TestRig {
async cleanup() {
// A session a test never closed keeps its CLI child forwarding PTY bytes
// into this worker's stdout; after vitest tears the worker down those
// writes EPIPE and fail an otherwise all-green run (#10969).
for (const ptyProcess of this.interactiveProcesses.splice(0)) {
// writes EPIPE and fail an otherwise all-green run (#10969). Signalling
// alone still returns with the child alive and writing, so wait for it to

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R11-1: [certifies-falsely] [new-surface] The root-cause diagnosis this change is justified by is contradicted by the CI logs of every run it cites, so the Fixes #10990 trailer and the two (#10990) code comments certify a root-cause fix for a failure class none of those runs exhibited.

The premise is that the OpenTUI leg "keeps reddening main without naming a single failing test", and that a run which passes everything and still exits non-zero must be an unhandled EPIPE from a CLI child outliving worker teardown. We fetched the archived logs of all six runs the description lists as that class. Every one of them names a failing test: five end on interactive/mid-turn-submit-interactive.test.ts > Mid-turn submit > exits on /quit while the response stream is held mid-turn, the sixth on context-compress-interactive.test.ts > should forward /compress instructions through to the side-query, and grep -ci EPIPE and grep -ci unhandled are both 0 in all six. That failure is an assertion inside the test body (submitUntilMidTurn calls rig.waitForText(HELD_MARKER, 30_000), mid-turn-submit-interactive.test.ts:164), so it happens before any afterEach runs and the new wait in cleanup() cannot reach it.

Why the issue named no test is also measurable, and it is not an unhandled error. The detector run that filed issue 10990 logged Could not download the log of job 100890051410 and Failing tests identified: 0, which is its own documented fallback (.github/workflows/main-ci-failure-issue.yml:90-95: "A missing log only costs precision: with no identifiable test the plan below falls back to the per-commit issue"). The detector script is unchanged since the failing commit, and run at HEAD on that same log it extracts the test cleanly.

The real cause is already in this PR's base. Commit 56f75adf29fix(cli): resolve the OpenTUI slash submit from the live buffer on Enter (PR 10986) — is an ancestor of this HEAD, and its own description records that the leg reddened on 5 of the 15 main runs since the mid-turn spec landed, every time on that same test. All six commits cited here predate it.

So merging as written closes issue 10990 as fixed while the recurring OpenTUI signature and the detector's log-fetch fallback both ship untouched, and the next red leg gets investigated through a closed issue whose stated cause was never the cause.

To be explicit about what this does not say: the harness wait itself is sound and we measured it working. Removing the process-group gate reds this PR's own waits for the CLI the installed bin wrapper relaunched to end case with expected 204 to be greater than or equal to 750, and it closes the prior round's Critical about the installed-release lane. Nothing needs reverting except the attribution.

Witness:

six cited runs, archived logs via gh api .../actions/jobs/<id>/logs:
  100790716774 100817894031 100844600702 100861214441 100890051410
    -> " FAIL interactive/mid-turn-submit-interactive.test.ts > Mid-turn submit > exits on /quit while the response stream is held mid-turn"
       "AssertionError: Held response never reached the screen, so the turn is not mid-stream: expected false to be true"
       " Test Files 1 failed | 8 passed | 1 skipped (10)"  Duration 195.80s
       then "##[error]Process completed with exit code 1."
  100892304566
    -> " FAIL context-compress-interactive.test.ts > should forward /compress instructions through to the side-query"
       " Test Files 1 failed | 9 passed | 1 skipped (11)"
  grep -ci EPIPE = 0 ; grep -ci unhandled = 0     (all six logs)

detector run 33832928669 / job 100899524656 (the run that filed issue 10990):
  "Failed jobs: 2"
  "##[warning]Could not download the log of job 100890051410"
  "Failing tests identified: 0"

same detector at HEAD, on that same log:
  "tests":[{"id":"interactive/mid-turn-submit-interactive.test.ts > Mid-turn submit > exits on /quit while the response stream is held mid-turn","key":"bab96578c699"}]

git diff b7815a7e1a..HEAD -- .github/scripts/ci/main-failure-signature.mjs -> empty
git merge-base --is-ancestor 56f75adf29 HEAD -> yes ; all six cited shas predate it

Keep the wait as teardown hygiene — the orphan measurement the description already contains justifies it on its own — and drop the attribution: the Fixes #10990 trailer, the (#10990) in this comment, and the (#10990) in the comment above sessionEndsWithin (test-helper.ts:157-159).

    // writes EPIPE and fail an otherwise all-green run (#10969). Signalling
    // alone still returns with the child alive and writing, so wait for it to
    // actually go away.

If the intent is to keep a tracker attached, point it at the recurring OpenTUI signature rather than at a per-commit alert. The repair that signature needs is submitUntilMidTurn's dependence on a contiguous run of PTY bytes under OpenTUI's cell-diffing repaint — which mid-turn-submit-interactive.test.ts:170-173 already documents, and which PR 10986 addressed — while the per-commit-issue symptom belongs to the log fetch in .github/workflows/main-ci-failure-issue.yml:88-96.

One premise not to break on the way: do not "fix" .github/scripts/ci/main-failure-signature.mjs:41 (const VITEST_FAIL_PATTERN = /^FAIL\s+(.+)$/;). cleanLine() at :49-55 strips ANSI, strips a leading timestamp, collapses whitespace and trims, so the space-prefixed vitest FAIL line does match — proven above by running it on the real log — and the file is byte-identical to the version that ran at the failing commit.

There is no test to add for an attribution change, and that absence is itself the evidence: all three cases this PR adds to integration-tests/test-helper.test.ts stay green whether or not the OpenTUI leg's real failure is addressed. The test that reds in five of the six logs is interactive/mid-turn-submit-interactive.test.ts > Mid-turn submit > exits on /quit while the response stream is held mid-turn under QWEN_E2E_RENDERER=opentui.

中文说明

本改动所依据的根因判断,被它自己引用的每一次 CI 运行的日志所否证;因此 Fixes #10990 这个 trailer 以及代码里两处 (#10990) 注释,认证的是一种那六次运行中一次都没有出现过的失败类别。

前提是这样的:OpenTUI 这条 leg "一直在让 main 变红却不指出任何一个失败的测试",而一个所有测试都通过却仍以非零码退出的 run 必然是 unhandled error —— 即某个 CLI 子进程比 worker teardown 活得更久,写入已关闭的管道而产生 EPIPE。我们取回了描述中列为该类别的全部六次运行的归档日志。它们每一次都指出了失败的测试:五次终止于 interactive/mid-turn-submit-interactive.test.ts > Mid-turn submit > exits on /quit while the response stream is held mid-turn,第六次终止于 context-compress-interactive.test.ts > should forward /compress instructions through to the side-query;并且六份日志中 grep -ci EPIPEgrep -ci unhandled 都为 0。那个失败是测试体内部的断言(submitUntilMidTurn 调用 rig.waitForText(HELD_MARKER, 30_000)mid-turn-submit-interactive.test.ts:164),发生在任何 afterEach 运行之前,因此 cleanup() 里新增的等待根本触达不到它。

"issue 为什么没有指出测试"同样是可测量的,而原因不是 unhandled error。创建 issue 10990 的那次 detector 运行记录了 Could not download the log of job 100890051410Failing tests identified: 0,这正是它自己有文档记载的降级路径(.github/workflows/main-ci-failure-issue.yml:90-95:"A missing log only costs precision: with no identifiable test the plan below falls back to the per-commit issue")。该 detector 脚本自失败的那个 commit 以来没有变化,而在 HEAD 上对同一份日志运行它,可以干净地把测试提取出来。

真正的原因已经在本 PR 的 base 里。commit 56f75adf29 —— fix(cli): resolve the OpenTUI slash submit from the live buffer on Enter(PR 10986)—— 是本 HEAD 的祖先,它自己的描述就记录了:自 mid-turn spec 落地以来,该 leg 在 15 次 main 运行中变红了 5 次,每次都是同一个测试。这里引用的六个 commit 全部早于它。

因此按现状合入的后果是:issue 10990 以"已修复"关闭,而真正反复出现的 OpenTUI 失败特征、以及 detector 的日志下载降级路径,都原样上线;下一次这条 leg 变红时,人们会顺着 issue 10990 去查一个已关闭、且其所陈述的原因从来就不是真实原因的问题。

需要明确本发现没有说什么:harness 的等待本身是可靠的,我们实测它有效。移除进程组门控会让本 PR 自己的 waits for the CLI the installed bin wrapper relaunched to end 用例变红(expected 204 to be greater than or equal to 750),而它确实关闭了上一轮关于 installed-release 通道的 Critical。除了归因之外,没有任何东西需要回退。

建议保留这个等待,作为 teardown 卫生改进 —— 描述中已有的孤儿进程实测数据本身就足以支撑它 —— 并去掉归因:Fixes #10990 trailer、本注释里的 (#10990),以及 sessionEndsWithin 上方注释(test-helper.ts:157-159)里的 (#10990)

如果本意是要挂一个 tracker,请把它指向那个反复出现的 OpenTUI 失败特征,而不是一个按 commit 生成的告警。该特征需要的修复是 submitUntilMidTurn 对"PTY 字节连续出现"的依赖 —— 在 OpenTUI 的按 cell diff 重绘下这一依赖不成立,mid-turn-submit-interactive.test.ts:170-173 已经记录了这一点,PR 10986 也已处理;而"按 commit 建 issue"这个症状属于 .github/workflows/main-ci-failure-issue.yml:88-96 的日志下载环节。

顺带一个不要破坏的前提:不要去"修" .github/scripts/ci/main-failure-signature.mjs:41const VITEST_FAIL_PATTERN = /^FAIL\s+(.+)$/;)。:49-55cleanLine() 会剥离 ANSI、剥掉行首时间戳、折叠空白并 trim,所以带前导空格的 vitest FAIL 行确实能匹配 —— 上面在真实日志上运行它已经证明 —— 而且该文件与失败 commit 上运行的版本逐字节相同。

归因类改动没有可添加的测试,而这个"没有"本身就是证据:本 PR 在 integration-tests/test-helper.test.ts 中新增的三个用例,无论 OpenTUI leg 的真实失败是否被处理都保持绿色。在六份日志中的五份里变红的测试是 interactive/mid-turn-submit-interactive.test.ts > Mid-turn submit > exits on /quit while the response stream is held mid-turn(在 QWEN_E2E_RENDERER=opentui 下)。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Implemented in 4e37d2e4a4: both (#10990) code attributions are dropped (the cleanup() comment and the comment above sessionEndsWithin), while the teardown wait, the process-group gate, and all three witness tests are unchanged, and .github/scripts/ci/main-failure-signature.mjs is untouched. Locally verified before editing: 56f75adf29 (PR 10986) is an ancestor of HEAD, the detector script is byte-identical since b7815a7e1a, and the log-download fallback is documented in .github/workflows/main-ci-failure-issue.yml:88-96. The archived CI logs themselves cannot be refetched from this sandbox (no GitHub credentials), so those rest on your archived-log witness.

One part stays open, and it is why this thread is left unresolved: the remaining Fixes #10990 trailer lives in the PR body — no branch commit carries it (verified with git log origin/main..HEAD) — and this bot has no credentials to edit the PR body or rewrite history. Please drop the trailer from the PR body and keep it out of the final merge message, so merging does not auto-close #10990 against a cause the logs contradict.

中文说明

已在 4e37d2e4a4 中落实:两处 (#10990) 代码归因均已删除(cleanup() 注释与 sessionEndsWithin 上方的注释),teardown 等待、进程组门控与三个见证测试全部原样保留,.github/scripts/ci/main-failure-signature.mjs 未触碰。编辑前已在本地验证:56f75adf29(PR 10986)是 HEAD 的祖先;detector 脚本自 b7815a7e1a 以来逐字节未变;日志下载降级路径在 .github/workflows/main-ci-failure-issue.yml:88-96 中有文档记载。归档 CI 日志本身无法在本沙箱内重新抓取(无 GitHub 凭据),该部分以你给出的归档日志见证为准。

仍有一项保持开放,这也是本 thread 不关闭的原因:剩余的 Fixes #10990 trailer 位于 PR body 中 —— 分支上没有任何 commit 携带它(已用 git log origin/main..HEAD 验证)—— 而本 bot 没有凭据编辑 PR body 或改写历史。请从 PR body 中删除该 trailer,并确保最终合并信息中也不含它,以免合并时以一个被日志否证的归因自动关闭 #10990

// actually go away.
for (const { ptyProcess, exited } of this.interactiveProcesses.splice(0)) {
try {
ptyProcess.kill();
} catch {
// Process may have already exited
}
const ended = await sessionEndsWithin(
ptyProcess.pid,
exited,
INTERACTIVE_EXIT_GRACE_MS,
);
if (!ended) {
console.warn(
`interactive CLI process group ${ptyProcess.pid} did not end ` +
`within ${INTERACTIVE_EXIT_GRACE_MS}ms; continuing cleanup`,
);
}
}

// Clean up test directory
Expand Down Expand Up @@ -988,7 +1060,10 @@ export class TestRig {
...e2eRendererEnv(renderer),
} as { [key: string]: string },
});
this.interactiveProcesses.push(ptyProcess);
const exited = new Promise<void>((resolve) => {
ptyProcess.onExit(() => resolve());
});
this.interactiveProcesses.push({ ptyProcess, exited });

ptyProcess.onData((data) => {
this._interactiveOutput += data;
Expand Down
Loading