mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-21 14:21:48 +00:00
test(managed-agent): release the held Hook after both streams are cut
The fixture now offers a hook-release mode that waits for <evidence>.release instead of sleeping for a fixed 20 s, and the test writes that file only after both readers abort. The deny therefore cannot land before the cut, so the gap the cell claims to test is the one it actually creates. assertResumedView now names what the Hook-held turn put on the ledger: one failed terminal for call_FG13E_HOLD, past the cut cursor, and one completion. Transport fidelity to a ledger that lost or duplicated the deny no longer passes. fg13-cases joins the Hosted artifact upload and the meta test pins it. Both design docs correct the CI trigger: pull_request selects the fast tier, and the full suite runs on the first maintainer approval of the head.
This commit is contained in:
parent
49d1ce8e7c
commit
1eb67df537
6 changed files with 50 additions and 13 deletions
1
.github/workflows/sdk-java.yml
vendored
1
.github/workflows/sdk-java.yml
vendored
|
|
@ -898,6 +898,7 @@ jobs:
|
|||
packages/managed-agent-server/target/fg10-cases/
|
||||
packages/managed-agent-server/target/fg11-cases/
|
||||
packages/managed-agent-server/target/fg12-cases/
|
||||
packages/managed-agent-server/target/fg13-cases/
|
||||
packages/managed-agent-server/target/l4-shell-lifecycle/
|
||||
retention-days: 7
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ result. The cursor machinery is shared; the Hook lane is the new subject.
|
|||
## Validation
|
||||
|
||||
The `hosted-harness-mysql` lane on Linux with MySQL 8.4 and a delegated
|
||||
cgroup v2 root is the evidence. The base is `main`, so the lane fires on
|
||||
`pull_request`. A skipped cell is not a passing gate. The cell adds 150 s to
|
||||
the shared fork: healthy 9630 s, fork 10230 s, Verify 181 minutes.
|
||||
cgroup v2 root is the evidence. `managed-agent-gate.yml` selects the `fast`
|
||||
tier on `pull_request`; the full suite runs on the first maintainer approval
|
||||
of the head commit, so a skipped lane is not a passing gate. The cell adds
|
||||
150 s to the shared fork: healthy 9630 s, fork 10230 s, Verify 181 minutes.
|
||||
|
|
|
|||
|
|
@ -66,5 +66,6 @@ Hook 状态本身不在公共流上,所以这一格钉的是台账与断流,
|
|||
## 验证
|
||||
|
||||
证据来自 Linux 上的 `hosted-harness-mysql` 车道,MySQL 8.4 与委派的 cgroup v2
|
||||
根。base 是 `main`,因此车道在 `pull_request` 上触发。跳过的用例不算通过。
|
||||
根。`managed-agent-gate.yml` 在 `pull_request` 上只选 `fast` 档;完整套件由维护者
|
||||
对 head 提交的首次批准触发,因此被跳过的车道不算通过。
|
||||
本格给共享 fork 增加 150 s:healthy 9630 s、fork 10230 s、Verify 181 分钟。
|
||||
|
|
|
|||
|
|
@ -63,9 +63,9 @@ class HostedHookStreamGapIT {
|
|||
private static final String TOKEN = "hook-stream-fixture-token";
|
||||
private static final String CAPABILITY = "sha256:" + "a".repeat(64);
|
||||
private static final String PROOF = "FG13E_TOOL_PROOF";
|
||||
// The Hook holds longer than both readers need to see the tool call and
|
||||
// be cut, so the deny lands while neither client is connected.
|
||||
private static final String HOOK_HOLD_MS = "20000";
|
||||
// The Hook is released by the test only after both clients are cut, so
|
||||
// the deny cannot land before the cut the way a fixed sleep could.
|
||||
private static final String HOOK_HOLD_MS = "0";
|
||||
private final ObjectMapper json = new ObjectMapper();
|
||||
private final HttpClient http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(3)).build();
|
||||
|
|
@ -136,7 +136,7 @@ class HostedHookStreamGapIT {
|
|||
hook.put("config", Map.of("type", "command", "command",
|
||||
quote(node) + " " + quote(program.toString()) + " hook "
|
||||
+ quote(evidence.toString()) + " "
|
||||
+ quote(HOOK_HOLD_MS) + " " + quote("hook-deny"),
|
||||
+ quote(HOOK_HOLD_MS) + " " + quote("hook-release"),
|
||||
"timeout", 60000));
|
||||
hook.put("onceKey", null);
|
||||
json.writeValue(temporary.resolve("hooks.json").toFile(),
|
||||
|
|
@ -197,6 +197,8 @@ class HostedHookStreamGapIT {
|
|||
// resume and redden the union as a duplicate.
|
||||
long publicCutAt = publicCut.lastSequence();
|
||||
long webShellCutAt = webShellCut.lastSequence();
|
||||
// Release only now, so the deny cannot land before the cut.
|
||||
releaseHook();
|
||||
awaitDenied();
|
||||
awaitCompleted(session, turn);
|
||||
List<JsonNode> publicView = new Observer("public", session,
|
||||
|
|
@ -247,20 +249,38 @@ class HostedHookStreamGapIT {
|
|||
/**
|
||||
* The resumed view closed the gap the cut opened and carries one
|
||||
* completion. The byte-for-byte ledger check lives in {@code
|
||||
* continueFrom}; this names what the Hook-held turn put there, so a
|
||||
* resumed view that lost the frames written during the cut, or replayed
|
||||
* the Turn's end, reddens here too.
|
||||
* continueFrom}; this names what the Hook-held turn put there: the
|
||||
* denied call's single failed terminal, after the cut, and one
|
||||
* completion, so a ledger that lost the deny or duplicated it reddens
|
||||
* even when transport matches it.
|
||||
*/
|
||||
private void assertResumedView(List<JsonNode> union, long cutAt) {
|
||||
assertThat(union.size())
|
||||
.as("frames landed after the cut and the resume served them")
|
||||
.isGreaterThan((int) cutAt);
|
||||
List<JsonNode> denied = union.stream()
|
||||
.filter(frame -> "item.tool_call.updated".equals(
|
||||
frame.path("type").asText()))
|
||||
.filter(frame -> "call_FG13E_HOLD".equals(frame.path("data")
|
||||
.path("toolCallId").asText()))
|
||||
.filter(frame -> "failed".equals(frame.path("data")
|
||||
.path("status").asText()))
|
||||
.toList();
|
||||
assertThat(denied).as("one failed terminal for the denied call: %s",
|
||||
union).hasSize(1);
|
||||
assertThat(denied.getFirst().path("sequence").asLong())
|
||||
.as("the deny landed after the cut, not before it")
|
||||
.isGreaterThan(cutAt);
|
||||
assertThat(union.stream().filter(frame -> "turn.completed".equals(
|
||||
frame.path("type").asText())).toList())
|
||||
.as("one completion, not one per reconnect: %s", union)
|
||||
.hasSize(1);
|
||||
}
|
||||
|
||||
private void releaseHook() throws IOException {
|
||||
Files.writeString(Path.of(evidence + ".release"), "release");
|
||||
}
|
||||
|
||||
private void awaitCompleted(String session, String turn) {
|
||||
waitFor(() -> assertThat(turnStatus(session, turn)).isEqualTo(
|
||||
"COMPLETED"));
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
fsyncSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
|
|
@ -43,6 +44,7 @@ function record(event, fields = {}) {
|
|||
const solo =
|
||||
signalMode === 'hook-solo' ||
|
||||
signalMode === 'hook-deny' ||
|
||||
signalMode === 'hook-release' ||
|
||||
signalMode === 'hook-fail';
|
||||
|
||||
if (mode === 'child') {
|
||||
|
|
@ -78,7 +80,15 @@ if (mode === 'child') {
|
|||
);
|
||||
record('hook.hold', { childPid: child.pid });
|
||||
}
|
||||
if (Number.isFinite(holdMs) && holdMs >= 0) {
|
||||
if (signalMode === 'hook-release') {
|
||||
// FG13e: the caller releases the Hook only after both clients are cut,
|
||||
// so the deny is guaranteed to land while neither is connected. A fixed
|
||||
// sleep cannot order the cut against the deny.
|
||||
const release = `${evidence}.release`;
|
||||
while (!existsSync(release)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
} else if (Number.isFinite(holdMs) && holdMs >= 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, holdMs));
|
||||
} else {
|
||||
// An indefinite hold must suspend this branch: setInterval alone keeps
|
||||
|
|
@ -91,7 +101,7 @@ if (mode === 'child') {
|
|||
child.kill('SIGKILL');
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
const deny = signalMode === 'hook-deny';
|
||||
const deny = signalMode === 'hook-deny' || signalMode === 'hook-release';
|
||||
// FG13f: a failed evaluation is what failClosed wraps. The process exits
|
||||
// without a decision so the receipt is a failure, not a deny.
|
||||
if (signalMode === 'hook-fail') {
|
||||
|
|
|
|||
|
|
@ -428,6 +428,10 @@ describe('Hosted real-process gates', () => {
|
|||
expect(upload.with.path).toContain(
|
||||
'packages/managed-agent-server/target/fg12-cases/',
|
||||
);
|
||||
// FG13e's Hook stream-gap evidence lives in target/fg13-cases/.
|
||||
expect(upload.with.path).toContain(
|
||||
'packages/managed-agent-server/target/fg13-cases/',
|
||||
);
|
||||
// The runner writes the Step 3 gate evidence under .qwen/, a hidden
|
||||
// path that upload-artifact drops silently without the flag. The arms
|
||||
// that write it run in the failover job, so the upload lives there.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue