From 3cd034f7a8dd5840aa4d18718294e49c08ff6eae Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 10 Aug 2026 16:11:53 -0700 Subject: [PATCH] fix(ci): release workflow checks fail on macOS Bash 3.2 (#121669) * fix(ci): keep release workflow checks portable on macOS * fix(ci): repair current main compact shard regressions * fix(ci): repair latest main validation drift * fix(approvals): restore native account ownership gates * fix(ci): repair skill workshop validation drift * fix(ci): align approval route selection with main * fix(ci): remove stale skill workshop test exports * fix(ci): align repairs with latest main --- .github/workflows/openclaw-release-checks.yml | 2 +- .../agent-runner-execution-progress.test.ts | 10 ++++ src/cron/service.stream-trigger.test.ts | 23 +++++---- .../service/timer.timeout-watchdog.test.ts | 38 ++++++++------ src/gateway/server-methods/setup-admission.ts | 1 + src/infra/approval-gateway-resolver.test.ts | 24 ++------- src/plugin-sdk/approval-native-helpers.ts | 2 + .../workshop/collection-reconcile.test.ts | 51 +++++++++++++------ src/skills/workshop/collection-review.test.ts | 6 ++- .../package-acceptance-workflow.test.ts | 13 +++-- 10 files changed, 101 insertions(+), 69 deletions(-) diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index a5480bd21e93..fadb07bbe35a 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -507,7 +507,7 @@ jobs: done if [[ "$qa_filter_seen" == "true" ]]; then - repo_live_suite_filter="$(IFS=,; printf '%s' "${repo_filter_tokens[*]:-}")" + repo_live_suite_filter="$(IFS=,; printf '%s' "${repo_filter_tokens[*]-}")" fi if [[ "${#disabled_required_lanes[@]}" -gt 0 ]]; then diff --git a/src/auto-reply/reply/agent-runner-execution-progress.test.ts b/src/auto-reply/reply/agent-runner-execution-progress.test.ts index 16cf6160c9ec..fe5c4fec4939 100644 --- a/src/auto-reply/reply/agent-runner-execution-progress.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-progress.test.ts @@ -135,7 +135,12 @@ describe("executeAgentTurn: lifecycle progress", () => { name: "read", phase: "start", status: "running", + summary: undefined, + progressText: undefined, + meta: undefined, commandBearing: false, + approvalId: undefined, + approvalSlug: undefined, }); }); @@ -227,7 +232,12 @@ describe("executeAgentTurn: lifecycle progress", () => { name: "bash", phase: "start", status: "running", + summary: undefined, + progressText: undefined, + meta: undefined, commandBearing: false, + approvalId: undefined, + approvalSlug: undefined, }); }); diff --git a/src/cron/service.stream-trigger.test.ts b/src/cron/service.stream-trigger.test.ts index 261b45122114..feac0d402605 100644 --- a/src/cron/service.stream-trigger.test.ts +++ b/src/cron/service.stream-trigger.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { CronService } from "./service.js"; import { setupCronServiceSuite } from "./service.test-harness.js"; +import type { CronServiceDeps } from "./service/state.js"; const { logger, makeStorePath } = setupCronServiceSuite({ prefix: "cron-stream-trigger-" }); @@ -194,7 +195,9 @@ describe("cron stream trigger composition", () => { it("reports a failed payload batch without reporting it fired", async () => { const { storePath } = await makeStorePath(); const onTriggerDisposition = vi.fn(); - const sendCronFailureAlert = vi.fn(async () => undefined); + const sendCronFailureAlert = vi.fn>( + async () => undefined, + ); const cron = new CronService({ storePath, cronEnabled: true, @@ -239,15 +242,15 @@ describe("cron stream trigger composition", () => { consecutiveErrors: 1, }); expect(sendCronFailureAlert).toHaveBeenCalledOnce(); - expect(sendCronFailureAlert).toHaveBeenCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ - text: - 'Automation "failing stream payload" failed 1 times\n' + - "Check automation history for details.", - }), - }), - ); + const alert = sendCronFailureAlert.mock.calls[0]?.[0]; + expect(alert?.channel).toBe("telegram"); + expect(alert?.to).toBe("19098680"); + expect(alert?.payload).toEqual({ + text: + 'Automation "failing stream payload" failed 1 times\n' + + "Check automation history for details.", + }); + expect(alert?.job.state.lastError).toBe("boom"); } finally { cron.stop(); } diff --git a/src/cron/service/timer.timeout-watchdog.test.ts b/src/cron/service/timer.timeout-watchdog.test.ts index cbe22fe85442..786cc3d30c8b 100644 --- a/src/cron/service/timer.timeout-watchdog.test.ts +++ b/src/cron/service/timer.timeout-watchdog.test.ts @@ -16,7 +16,7 @@ import type { CronAgentExecutionStarted, CronJob, } from "../types.js"; -import { createCronServiceState } from "./state.js"; +import { createCronServiceState, type CronServiceDeps } from "./state.js"; import { onTimer } from "./timer.test-support.js"; const timerRegressionFixtures = setupCronRegressionFixtures({ @@ -723,7 +723,9 @@ describe("cron service timer regressions", () => { const started = createDeferred(); let abortObserved = false; const cleanupTimedOutAgentRun = vi.fn(async () => {}); - const sendCronFailureAlert = vi.fn(async () => {}); + const sendCronFailureAlert = vi.fn>( + async () => {}, + ); const state = createCronServiceState({ cronEnabled: true, storePath: store.storePath, @@ -775,23 +777,29 @@ describe("cron service timer regressions", () => { await timerPromise; const job = requireJob(state, "isolated-before-agent-reply-unhandled-82811"); + const diagnostic = + "cron: isolated agent run stalled before execution start (last phase: runtime-plugins)"; expect(abortObserved).toBe(true); expect(job.state.lastStatus).toBe("error"); - expect(job.state.lastError).toContain("stalled before execution start"); - expect(job.state.lastError).toContain("runtime-plugins"); + expect(job.state.lastError).toBe(diagnostic); + expect(job.state.lastDiagnosticSummary).toBe(diagnostic); + expect(job.state.lastDiagnostics).toEqual({ + summary: diagnostic, + entries: [ + { source: "cron-setup", severity: "error", message: diagnostic, ts: scheduledAt }, + ], + }); expect(cleanupTimedOutAgentRun).toHaveBeenCalledTimes(1); expect(sendCronFailureAlert).toHaveBeenCalledTimes(1); - expect(sendCronFailureAlert).toHaveBeenCalledWith( - expect.objectContaining({ - channel: "telegram", - to: "12345", - payload: expect.objectContaining({ - text: - 'Automation "before agent reply unhandled regression" failed 1 times\n' + - "Check automation history for details.", - }), - }), - ); + const alert = sendCronFailureAlert.mock.calls[0]?.[0]; + expect(alert?.channel).toBe("telegram"); + expect(alert?.to).toBe("12345"); + expect(alert?.payload).toEqual({ + text: + 'Automation "before agent reply unhandled regression" failed 1 times\n' + + "Check automation history for details.", + }); + expect(alert?.job.state.lastDiagnosticSummary).toBe(diagnostic); } finally { vi.useRealTimers(); } diff --git a/src/gateway/server-methods/setup-admission.ts b/src/gateway/server-methods/setup-admission.ts index 7190943cc447..2f489fdc5e00 100644 --- a/src/gateway/server-methods/setup-admission.ts +++ b/src/gateway/server-methods/setup-admission.ts @@ -28,6 +28,7 @@ export async function runExclusiveSystemAgentSetupActivation( } } +/** Resolves after both the wizard runner and its setup-target admission have settled. */ export function whenAdmittedWizardSessionSettled(session: { whenSettled(): Promise; }): Promise { diff --git a/src/infra/approval-gateway-resolver.test.ts b/src/infra/approval-gateway-resolver.test.ts index 58bd4af91515..362923b41b51 100644 --- a/src/infra/approval-gateway-resolver.test.ts +++ b/src/infra/approval-gateway-resolver.test.ts @@ -250,26 +250,12 @@ describe("resolveApprovalOverGateway", () => { it("sends channel custody to an injected canonical runtime", async () => { const injectedRequest = vi.fn(async () => ({ applied: true, approval: recordedApproval })); - const scopedRequest: GatewayNativeApprovalRuntime["request"] = vi.fn( - async (method: string): Promise => { - const fixture = - method === "exec.approval.list" - ? [ - { - id: "approval-1", - request: { - command: "printf approval", - turnSourceChannel: "imessage", - turnSourceAccountId: "personal", - }, - }, - ] - : { applied: true, approval: recordedApproval }; - return fixture as T; - }, - ) as GatewayNativeApprovalRuntime["request"]; + const scopedRequest = vi.fn(); const runtime = { - request: scopedRequest, + request: async (): Promise => { + scopedRequest(); + throw new Error("unexpected scoped approval request"); + }, requestRoute: vi.fn(), routeCoordinator: { doesAccountHandleRequest: () => true } as never, subscribe: vi.fn(), diff --git a/src/plugin-sdk/approval-native-helpers.ts b/src/plugin-sdk/approval-native-helpers.ts index b709b22a6096..88b40802dbdb 100644 --- a/src/plugin-sdk/approval-native-helpers.ts +++ b/src/plugin-sdk/approval-native-helpers.ts @@ -773,6 +773,8 @@ export function createNativeApprovalChannelRouteGates { + // Per-account runtimes report raw candidates here. The route coordinator rejects + // unbound multi-account groups as ambiguous before any runtime can deliver. const accountId = input.accountId ?? params.resolveDefaultAccountId(input.cfg); const eligibleAccountIds = params.isTransportEnabled({ cfg: input.cfg, accountId }) ? [accountId] diff --git a/src/skills/workshop/collection-reconcile.test.ts b/src/skills/workshop/collection-reconcile.test.ts index 4e9a343491fb..e3d7c49d1cc1 100644 --- a/src/skills/workshop/collection-reconcile.test.ts +++ b/src/skills/workshop/collection-reconcile.test.ts @@ -20,10 +20,28 @@ import { getArchivedSkillFiles } from "./curator.js"; import { readSkillProposalTargetTreeSha256 } from "./proposal-bundle.js"; import { withSkillCollectionLock } from "./target-lock.js"; +type CopyDirectoryHook = ( + source: unknown, + destination: unknown, + options?: unknown, +) => Promise; + +const copyDirectoryBefore = vi.hoisted(() => vi.fn(async () => {})); +const copyDirectoryAfter = vi.hoisted(() => vi.fn(async () => {})); const dispatchCommittedSkillChangeBestEffort = vi.hoisted(() => vi.fn(async (_event: { action: string }) => {}), ); const snapshotCommittedSkillArtifactBestEffort = vi.hoisted(() => vi.fn(async () => undefined)); +vi.mock("node:fs/promises", async () => { + const actual = await vi.importActual("node:fs/promises"); + const cp: typeof actual.cp = async (source, destination, options) => { + await copyDirectoryBefore(source, destination, options); + await actual.cp(source, destination, options); + await copyDirectoryAfter(source, destination, options); + }; + const patched = { ...actual, cp }; + return { ...patched, default: patched }; +}); vi.mock("../lifecycle/skill-change-hook.js", () => ({ hasCommittedSkillChangeHooks: () => true, snapshotCommittedSkillArtifactBestEffort, @@ -35,6 +53,10 @@ let testState: OpenClawTestState; let workspaceDir: string; beforeEach(async () => { + copyDirectoryBefore.mockReset(); + copyDirectoryBefore.mockResolvedValue(undefined); + copyDirectoryAfter.mockReset(); + copyDirectoryAfter.mockResolvedValue(undefined); dispatchCommittedSkillChangeBestEffort.mockClear(); snapshotCommittedSkillArtifactBestEffort.mockReset(); snapshotCommittedSkillArtifactBestEffort.mockResolvedValue(undefined); @@ -239,9 +261,7 @@ describe("skill collection reconciliation", () => { await fs.mkdir(path.dirname(supportFile), { recursive: true }); await fs.writeFile(supportFile, "Before\n", "utf8"); const receipt = await readCollectionReceipt(); - const copy = fs.cp.bind(fs); - const copySpy = vi.spyOn(fs, "cp").mockImplementation(async (source, destination, options) => { - await copy(source, destination, options); + copyDirectoryAfter.mockImplementationOnce(async () => { await fs.appendFile(supportFile, "External edit\n", "utf8"); }); @@ -260,7 +280,7 @@ describe("skill collection reconciliation", () => { ], }), ).rejects.toThrow("Skill tree changed before collection mutation: procedure"); - copySpy.mockRestore(); + copyDirectoryAfter.mockReset(); await expect(fs.readFile(path.join(skillDir, "SKILL.md"), "utf8")).resolves.toContain( "# Original", @@ -450,12 +470,16 @@ describe("skill collection reconciliation", () => { }, ], }); - const skillDir = path.join(workspaceDir, "skills", "procedure"); + const canonicalWorkspaceDir = await fs.realpath(workspaceDir); + const skillDir = path.join(canonicalWorkspaceDir, "skills", "procedure"); const skillFile = path.join(skillDir, "SKILL.md"); - const backupRoot = path.join(testState.stateDir, "skill-workshop", "collection-backups"); - const originalCopy = fs.cp.bind(fs); + const backupRoot = path.join( + await fs.realpath(testState.stateDir), + "skill-workshop", + "collection-backups", + ); let failed = false; - const copySpy = vi.spyOn(fs, "cp").mockImplementation(async (source, destination, options) => { + copyDirectoryBefore.mockImplementation(async (source, destination) => { if ( !failed && String(source).startsWith(backupRoot) && @@ -465,7 +489,6 @@ describe("skill collection reconciliation", () => { failed = true; throw new Error("forced restore copy failure"); } - await originalCopy(source, destination, options); }); try { @@ -473,7 +496,7 @@ describe("skill collection reconciliation", () => { restoreLatestSkillCollectionBackup({ workspaceDir, env: testState.env }), ).rejects.toThrow("forced restore copy failure"); } finally { - copySpy.mockRestore(); + copyDirectoryBefore.mockReset(); } await expect(fs.readFile(skillFile, "utf8")).resolves.toContain("# Clean"); @@ -498,14 +521,12 @@ describe("skill collection reconciliation", () => { }, ], }); - const skillDir = path.join(workspaceDir, "skills", "procedure"); + const skillDir = path.join(await fs.realpath(workspaceDir), "skills", "procedure"); const beforeVersion = getSkillsSnapshotVersion(); - const originalCopy = fs.cp.bind(fs); - const copySpy = vi.spyOn(fs, "cp").mockImplementation(async (source, destination, options) => { + copyDirectoryBefore.mockImplementation(async (source, destination) => { if (path.resolve(String(destination)) === path.resolve(skillDir)) { throw new Error(`forced restore copy failure: ${String(source)}`); } - await originalCopy(source, destination, options); }); try { @@ -513,7 +534,7 @@ describe("skill collection reconciliation", () => { restoreLatestSkillCollectionBackup({ workspaceDir, env: testState.env }), ).rejects.toThrow("current collection was not restored"); } finally { - copySpy.mockRestore(); + copyDirectoryBefore.mockReset(); } expect(getSkillsSnapshotVersion()).toBeGreaterThan(beforeVersion); diff --git a/src/skills/workshop/collection-review.test.ts b/src/skills/workshop/collection-review.test.ts index b5f0ef247381..79c4dc0ed40b 100644 --- a/src/skills/workshop/collection-review.test.ts +++ b/src/skills/workshop/collection-review.test.ts @@ -367,6 +367,7 @@ describe("skill collection review", () => { it("groups symlink aliases before comparing shared-workspace identities", async () => { const workspaceDir = await tempDirs.make("openclaw-collection-review-real-workspace-"); + const canonicalWorkspaceDir = await fs.realpath(workspaceDir); const aliasParent = await tempDirs.make("openclaw-collection-review-alias-parent-"); const workspaceAlias = path.join(aliasParent, "workspace-alias"); await fs.symlink( @@ -400,7 +401,7 @@ describe("skill collection review", () => { onError, }); - expect(onError).toHaveBeenCalledWith(expect.any(Error), workspaceDir); + expect(onError).toHaveBeenCalledWith(expect.any(Error), canonicalWorkspaceDir); expect(runWithGatewayIndependentRootWorkAdmission).not.toHaveBeenCalled(); expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); @@ -450,6 +451,7 @@ describe("skill collection review", () => { it("admits and reports each workspace independently", async () => { const oversizedWorkspace = await tempDirs.make("openclaw-collection-review-failed-"); + const canonicalOversizedWorkspace = await fs.realpath(oversizedWorkspace); const healthyWorkspace = await tempDirs.make("openclaw-collection-review-healthy-"); await writeWorkspaceSkills(oversizedWorkspace, [ { name: "oversized", description: "Oversized", body: "x".repeat(240_001) }, @@ -489,7 +491,7 @@ describe("skill collection review", () => { }); expect(runWithGatewayIndependentRootWorkAdmission).toHaveBeenCalledTimes(2); - expect(onError).toHaveBeenCalledWith(expect.any(Error), oversizedWorkspace); + expect(onError).toHaveBeenCalledWith(expect.any(Error), canonicalOversizedWorkspace); expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); }); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 4916e686a243..c9f786a0cf44 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -3871,7 +3871,7 @@ describe("package artifact reuse", () => { expect(workflow).toContain("repo_live_suite_filter:"); expect(workflow).toContain('repo_filter_tokens+=("$token")'); expect(workflow).toContain( - 'repo_live_suite_filter="$(IFS=,; printf \'%s\' "${repo_filter_tokens[*]:-}")"', + 'repo_live_suite_filter="$(IFS=,; printf \'%s\' "${repo_filter_tokens[*]-}")"', ); expect(workflow).toContain("cross_os_suite_filter:"); expect(workflow).toContain("advisory: false"); @@ -6356,12 +6356,11 @@ wait_for_run plugin-clawhub-new.yml 123 "${expectedSha}" || status=$? const releaseChecksParent = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "release_checks"); expect(releaseChecksParent["runs-on"]).toBe("blacksmith-4vcpu-ubuntu-2404"); expect(releaseChecksParent["timeout-minutes"]).toBe(420); - const releasePackageTimeouts = Object.fromEntries( - profiles.map((profile) => [ - profile, - releasePackagePaths[profile].reduce((total, timeout) => total + timeout, 0), - ]), - ) as Record<(typeof profiles)[number], number>; + const releasePackageTimeouts = { + beta: releasePackagePaths.beta.reduce((total, timeout) => total + timeout, 0), + stable: releasePackagePaths.stable.reduce((total, timeout) => total + timeout, 0), + full: releasePackagePaths.full.reduce((total, timeout) => total + timeout, 0), + }; expect(releasePackageTimeouts).toEqual({ beta: 280, stable: 280, full: 310 }); for (const [profile, childTimeout] of Object.entries(releasePackageTimeouts)) { expect(childTimeout, `release-package:${profile}`).toBeLessThanOrEqual(420);