fix(ci): keep full release validation aligned with runtime state (#116653)

Align Telegram artifact reuse and packaged Docker validation with current GitHub Actions and runtime state contracts. Refresh affected regression fixtures and isolate test state.
This commit is contained in:
Vincent Koc
2026-07-31 23:03:02 +08:00
committed by GitHub
parent f18d619c02
commit b45ca07ec9
9 changed files with 152 additions and 92 deletions

View File

@@ -352,7 +352,7 @@ jobs:
}
attempt_started_at="$(jq -er '.run_started_at | fromdateiso8601' <<< "$attempt_json")"
if [[ "$ARTIFACT_RUN_ID" == "$GITHUB_RUN_ID" ]]; then
jq -e '(.status == "pending" or .status == "queued" or .status == "in_progress") and .conclusion == null' \
jq -e '(.status == "pending" or .status == "queued" or .status == "requested" or .status == "waiting" or .status == "in_progress") and .conclusion == null' \
<<< "$attempt_json" >/dev/null || {
echo "Current-run Package Telegram artifact is not from the active workflow attempt." >&2
exit 1

View File

@@ -902,12 +902,18 @@ describe("handleTelegramAction", () => {
expect(entries[0]).toMatchObject({
channel: "telegram",
to: "12345",
payloads: [
{
text: "times out after queue write",
delivery: { pin: { enabled: true, required: true } },
},
],
preparedBatch: {
sourcePayloadCount: 1,
entries: [
{
status: "accepted",
payload: {
text: "times out after queue write",
delivery: { pin: { enabled: true, required: true } },
},
},
],
},
session: { key: "agent:main:telegram:direct:12345", agentId: "main" },
gatewayClientScopes: ["operator.write"],
retryCount: 0,
@@ -917,12 +923,20 @@ describe("handleTelegramAction", () => {
.mockImplementationOnce(async () => {
const entries = readDurableQueueEntries();
const liveEntry = entries.find((entry) =>
JSON.stringify(entry.payloads).includes("delivers after queue write"),
JSON.stringify(entry.preparedBatch).includes("delivers after queue write"),
);
expect(liveEntry).toMatchObject({
channel: "telegram",
to: "12345",
payloads: [{ text: "delivers after queue write" }],
preparedBatch: {
sourcePayloadCount: 1,
entries: [
{
status: "accepted",
payload: { text: "delivers after queue write" },
},
],
},
retryCount: 0,
});
return { channel: "telegram", messageId: "tg-ok" };
@@ -978,12 +992,18 @@ describe("handleTelegramAction", () => {
const retryableEntries = readDurableQueueEntries();
expect(retryableEntries).toHaveLength(1);
expect(retryableEntries[0]).toMatchObject({
payloads: [
{
text: "times out after queue write",
delivery: { pin: { enabled: true, required: true } },
},
],
preparedBatch: {
sourcePayloadCount: 1,
entries: [
{
status: "accepted",
payload: {
text: "times out after queue write",
delivery: { pin: { enabled: true, required: true } },
},
},
],
},
retryCount: 1,
});
expect(String(retryableEntries[0]?.lastError)).toContain("telegram timeout");
@@ -1004,12 +1024,18 @@ describe("handleTelegramAction", () => {
});
expect(readDurableQueueEntries()).toHaveLength(1);
expect(readDurableQueueEntries()[0]).toMatchObject({
payloads: [
{
text: "times out after queue write",
delivery: { pin: { enabled: true, required: true } },
},
],
preparedBatch: {
sourcePayloadCount: 1,
entries: [
{
status: "accepted",
payload: {
text: "times out after queue write",
delivery: { pin: { enabled: true, required: true } },
},
},
],
},
retryCount: 1,
});
} finally {

View File

@@ -3,11 +3,12 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { enqueueCommitmentExtraction } from "../../dist/commitments/runtime.js";
import { fileURLToPath } from "node:url";
import {
drainCommitmentExtractionQueue,
enqueueCommitmentExtraction,
resetCommitmentExtractionRuntimeForTests,
} from "../../dist/commitments/runtime.test-support.js";
} from "../../dist/commitments/runtime.js";
import {
listCommitments,
listDueCommitmentsForSession,
@@ -128,10 +129,33 @@ async function runPackagedDoctor(stateDir: string): Promise<void> {
);
}
function verifyRuntimeIgnoresLegacyJsonInChild(nowMs: number): void {
// The shared state database caches handles for the process lifetime. Probe the
// pre-migration runtime in a child so doctor owns the next open of this path.
const result = spawnSync(
"tsx",
[fileURLToPath(import.meta.url), "--verify-legacy-unread", String(nowMs)],
{
cwd: process.cwd(),
env: process.env,
encoding: "utf8",
timeout: 120_000,
},
);
assert(
result.status === 0,
`legacy runtime probe failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
}
async function verifyRuntimeIgnoresLegacyJson(nowMs: number): Promise<void> {
const beforeDoctor = await listCommitments({ nowMs });
assert(beforeDoctor.length === 0, "runtime imported legacy JSON without doctor");
}
async function verifyDoctorImportAndRuntimeIsolation() {
await withStateDir("commitments-doctor", async (stateDir) => {
const nowMs = Date.parse("2026-04-29T17:00:00.000Z");
const cfg = { commitments: { enabled: true } };
const sourcePath = path.join(stateDir, "commitments", "commitments.json");
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
await fs.writeFile(
@@ -140,13 +164,7 @@ async function verifyDoctorImportAndRuntimeIsolation() {
"utf8",
);
const beforeDoctor = await listDueCommitmentsForSession({
cfg,
agentId: "main",
sessionKey: "agent:main:qa-channel:commitments",
nowMs,
});
assert(beforeDoctor.length === 0, "runtime imported legacy JSON without doctor");
verifyRuntimeIgnoresLegacyJsonInChild(nowMs);
await fs.access(sourcePath);
await runPackagedDoctor(stateDir);
@@ -161,16 +179,11 @@ async function verifyDoctorImportAndRuntimeIsolation() {
}
});
const due = await listDueCommitmentsForSession({
cfg,
agentId: "main",
sessionKey: "agent:main:qa-channel:commitments",
nowMs,
});
assert(due.length === 1, `unexpected imported due count ${due.length}`);
assert(!("sourceUserText" in due[0]), "legacy source user text surfaced after import");
const imported = await listCommitments({ nowMs });
assert(imported.length === 1, `unexpected imported commitment count ${imported.length}`);
assert(!("sourceUserText" in imported[0]), "legacy source user text surfaced after import");
assert(
!("sourceAssistantText" in due[0]),
!("sourceAssistantText" in imported[0]),
"legacy source assistant text surfaced after import",
);
});
@@ -223,7 +236,11 @@ async function verifyExpiryTransition() {
});
}
await verifyExtractionRemainsRetired();
await verifyDoctorImportAndRuntimeIsolation();
await verifyExpiryTransition();
console.log("OK");
if (process.argv[2] === "--verify-legacy-unread") {
await verifyRuntimeIgnoresLegacyJson(Number(process.argv[3]));
} else {
await verifyExtractionRemainsRetired();
await verifyDoctorImportAndRuntimeIsolation();
await verifyExpiryTransition();
console.log("OK");
}

View File

@@ -50,10 +50,8 @@ function messageText(content: unknown): string {
.join("");
}
async function verifyRuntimeContextTranscriptShape(root: string) {
const sessionFile = path.join(root, ".openclaw", "agents", "main", "sessions", "runtime.jsonl");
await fs.mkdir(path.dirname(sessionFile), { recursive: true });
const sessionManager = SessionManager.open(sessionFile);
async function verifyRuntimeContextTranscriptShape() {
const sessionManager = SessionManager.inMemory();
const effectivePrompt = [
"visible ask",
"",
@@ -233,8 +231,7 @@ async function verifyDoctorRepair(root: string) {
})) as TranscriptEntry[];
const ids = entries.map((entryValue) => (entryValue as { id?: string }).id).filter(Boolean);
assert(
JSON.stringify(ids) ===
JSON.stringify(["broken-session", "parent", "plain-user", "plain-assistant"]),
JSON.stringify(ids) === JSON.stringify(["broken", "parent", "plain-user", "plain-assistant"]),
`doctor kept wrong active branch: ${JSON.stringify(ids)}`,
);
assert(
@@ -256,7 +253,7 @@ async function main() {
setEnvValue("OPENCLAW_STATE_DIR", stateDir);
setEnvValue("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json"));
try {
await verifyRuntimeContextTranscriptShape(root);
await verifyRuntimeContextTranscriptShape();
await verifyDoctorRepair(root);
console.log("session runtime context Docker E2E passed");
} finally {

View File

@@ -26,7 +26,7 @@ file_list_is_docsish_only() {
if ! path_is_docsish "$path"; then
return 1
fi
done <<<"$files"
done < <(printf '%s\n' "$files")
[ "$saw_any" = "true" ]
}

View File

@@ -298,40 +298,47 @@ describe("Engine contract tests", () => {
});
it("delegateCompactionToRuntime returns successor sessionTarget without sessionFile", async () => {
compactEmbeddedAgentSessionDirectMock.mockResolvedValueOnce({
ok: true,
compacted: true,
reason: undefined,
result: {
summary: "summary",
firstKeptEntryId: "entry-1",
tokensBefore: 100,
tokensAfter: 40,
details: undefined,
sessionId: "s3-successor",
sessionFile: "sqlite:main:s3-successor:/tmp/openclaw-agent.sqlite",
},
});
const root = fs.mkdtempSync(path.join(os.tmpdir(), "context-successor-target-"));
const storePath = path.join(root, "openclaw-agent.sqlite");
try {
compactEmbeddedAgentSessionDirectMock.mockResolvedValueOnce({
ok: true,
compacted: true,
reason: undefined,
result: {
summary: "summary",
firstKeptEntryId: "entry-1",
tokensBefore: 100,
tokensAfter: 40,
details: undefined,
sessionId: "s3-successor",
sessionFile: `sqlite:main:s3-successor:${storePath}`,
},
});
const result = await delegateCompactionToRuntime({
sessionId: "s3",
sessionKey: "agent:main:s3",
tokenBudget: 4096,
runtimeContext: {
workspaceDir: "/tmp/workspace",
},
});
expect(result.result).toMatchObject({
sessionId: "s3-successor",
sessionTarget: {
agentId: "main",
sessionId: "s3-successor",
const result = await delegateCompactionToRuntime({
sessionId: "s3",
sessionKey: "agent:main:s3",
storePath: "/tmp/openclaw-agent.sqlite",
},
});
expect(result.result).not.toHaveProperty("sessionFile");
tokenBudget: 4096,
runtimeContext: {
workspaceDir: "/tmp/workspace",
},
});
expect(result.result).toMatchObject({
sessionId: "s3-successor",
sessionTarget: {
agentId: "main",
sessionId: "s3-successor",
sessionKey: "agent:main:s3",
storePath,
},
});
expect(result.result).not.toHaveProperty("sessionFile");
} finally {
closeOpenClawAgentDatabasesForTest();
fs.rmSync(root, { recursive: true, force: true });
}
});
it("allows the caller key to rebind to a legacy successor session", async () => {

View File

@@ -243,7 +243,7 @@ async function main() {
arguments: {
session_key: "agent:main:main",
after_cursor: 0,
timeout_ms: 10_000,
timeout_ms: 120_000,
},
}),
gateway.request<{ runId?: string; status?: string }>("chat.send", {
@@ -284,11 +284,15 @@ async function main() {
);
const channelMessage = `hello from docker ${randomUUID()}`;
await gateway.request("chat.send", {
const channelRun = await gateway.request<{ runId?: string; status?: string }>("chat.send", {
sessionKey: "agent:main:main",
message: channelMessage,
idempotencyKey: randomUUID(),
});
assert(
channelRun.status === "started" && typeof channelRun.runId === "string",
`channel chat.send did not start: ${JSON.stringify(channelRun)}`,
);
const rawGatewayUserMessage = await waitFor(
"raw gateway user session.message",
() =>
@@ -370,6 +374,15 @@ async function main() {
);
}
assert(helpNotification.content === channelMessage, "expected Claude channel content");
const channelRunResult = await gateway.request<{ status?: string }>(
"agent.wait",
{ runId: channelRun.runId, timeoutMs: 240_000 },
{ timeoutMs: 245_000 },
);
assert(
channelRunResult.status === "ok",
`agent.wait failed for ${channelRun.runId}: ${JSON.stringify(channelRunResult)}`,
);
await mcp.notification({
method: "notifications/claude/channel/permission_request",

View File

@@ -11,8 +11,8 @@ describe("Docker E2E client scripts", () => {
const source = readScript("scripts/e2e/commitments-safety-docker-client.ts");
expect(source).toContain("../../dist/commitments/runtime.js");
expect(source).toContain("../../dist/commitments/runtime.test-support.js");
expect(source).toContain("../../dist/commitments/store.js");
expect(source).toContain("resetCommitmentExtractionRuntimeForTests");
expect(source).toContain("verifyExtractionRemainsRetired()");
expect(source).toContain("verifyDoctorImportAndRuntimeIsolation()");
expect(source).toContain("verifyExpiryTransition()");
@@ -27,7 +27,8 @@ describe("Docker E2E client scripts", () => {
expect(source).toContain(
"../../dist/agents/embedded-agent-runner/run/runtime-context-prompt.js",
);
expect(source).toContain("verifyRuntimeContextTranscriptShape(root)");
expect(source).toContain("SessionManager.inMemory()");
expect(source).toContain("verifyRuntimeContextTranscriptShape()");
expect(source).toContain("verifyDoctorRepair(root)");
expect(source).toContain("<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>");
expect(source).toContain("openclaw.runtime-context");

View File

@@ -245,7 +245,7 @@ function runNpmTelegramInputValidation(overrides: Record<string, string>) {
function runNpmTelegramArtifactValidation(params: {
currentRunId: string;
producerRunId: string;
producerStatus: "completed" | "in_progress" | "pending" | "queued";
producerStatus: "completed" | "in_progress" | "pending" | "queued" | "requested" | "waiting";
producerConclusion: "success" | null;
}) {
const job = workflowJob(NPM_TELEGRAM_WORKFLOW, "run_package_telegram_e2e");
@@ -3251,8 +3251,7 @@ describe("package artifact reuse", () => {
'--arg digest "sha256:${ARTIFACT_DIGEST}"',
"actions/runs/${ARTIFACT_RUN_ID}/attempts/${ARTIFACT_RUN_ATTEMPT}",
'if [[ "$ARTIFACT_RUN_ID" == "$GITHUB_RUN_ID" ]]',
'.status == "pending"',
'.status == "queued" or .status == "in_progress"',
'.status == "pending" or .status == "queued" or .status == "requested" or .status == "waiting" or .status == "in_progress"',
".conclusion == null",
"Package Telegram artifact predates the active producer run attempt.",
'.status == "completed"',
@@ -3309,7 +3308,7 @@ describe("package artifact reuse", () => {
expect(result.status, result.stderr).toBe(0);
});
it("accepts active artifacts while GitHub reports the workflow as pending", () => {
it("accepts active artifacts while GitHub still reports the workflow as pending", () => {
const result = runNpmTelegramArtifactValidation({
currentRunId: "123",
producerConclusion: null,