From b45ca07ec947142192b8db90a8ad07513ff5f67f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 23:03:02 +0800 Subject: [PATCH] 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. --- .github/workflows/npm-telegram-beta-e2e.yml | 2 +- .../telegram/src/action-runtime.test.ts | 66 +++++++++++------ .../e2e/commitments-safety-docker-client.ts | 63 ++++++++++------ .../session-runtime-context-docker-client.ts | 11 ++- scripts/pr-lib/common.sh | 2 +- src/context-engine/context-engine.test.ts | 71 ++++++++++--------- .../runtime/mcp-channels-docker-client.ts | 17 ++++- test/scripts/docker-e2e-clients.test.ts | 5 +- .../package-acceptance-workflow.test.ts | 7 +- 9 files changed, 152 insertions(+), 92 deletions(-) diff --git a/.github/workflows/npm-telegram-beta-e2e.yml b/.github/workflows/npm-telegram-beta-e2e.yml index 2516ceb3658c..374525f27aca 100644 --- a/.github/workflows/npm-telegram-beta-e2e.yml +++ b/.github/workflows/npm-telegram-beta-e2e.yml @@ -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 diff --git a/extensions/telegram/src/action-runtime.test.ts b/extensions/telegram/src/action-runtime.test.ts index b89a034fe3fc..fbdef4e9e1cb 100644 --- a/extensions/telegram/src/action-runtime.test.ts +++ b/extensions/telegram/src/action-runtime.test.ts @@ -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 { diff --git a/scripts/e2e/commitments-safety-docker-client.ts b/scripts/e2e/commitments-safety-docker-client.ts index b63f4c418c24..93a9dcf05e22 100644 --- a/scripts/e2e/commitments-safety-docker-client.ts +++ b/scripts/e2e/commitments-safety-docker-client.ts @@ -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 { ); } +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 { + 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"); +} diff --git a/scripts/e2e/session-runtime-context-docker-client.ts b/scripts/e2e/session-runtime-context-docker-client.ts index bbf005534f26..2babd756a05a 100644 --- a/scripts/e2e/session-runtime-context-docker-client.ts +++ b/scripts/e2e/session-runtime-context-docker-client.ts @@ -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 { diff --git a/scripts/pr-lib/common.sh b/scripts/pr-lib/common.sh index 6c9fc93d8066..75e5b253555e 100644 --- a/scripts/pr-lib/common.sh +++ b/scripts/pr-lib/common.sh @@ -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" ] } diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 6ae71038ecc6..88c91026f261 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -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 () => { diff --git a/test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts b/test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts index efb745dc6722..656cee485aa0 100644 --- a/test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts +++ b/test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts @@ -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", diff --git a/test/scripts/docker-e2e-clients.test.ts b/test/scripts/docker-e2e-clients.test.ts index 9511c416e328..722b12c3810e 100644 --- a/test/scripts/docker-e2e-clients.test.ts +++ b/test/scripts/docker-e2e-clients.test.ts @@ -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("<<>>"); expect(source).toContain("openclaw.runtime-context"); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 05acc9ed7a36..2b0d69004372 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -245,7 +245,7 @@ function runNpmTelegramInputValidation(overrides: Record) { 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,