From 73bdb4b924f6db3c4ab45c5e40fbf61b06fa56a0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 8 Aug 2026 20:32:11 -0700 Subject: [PATCH] feat(agents): record run-end worktree cleanup outcome; prove Workboard dirty retention (#120434) * feat(agents): record run-end worktree cleanup outcome Persist removed, retained, and failed run-end cleanup outcomes on managed worktree records. Operators and QA can inspect the durable fact through worktrees.list and openclaw worktrees list --json. Release note: Managed worktree run-end cleanup now records why a checkout was removed or retained in worktree list JSON. * test(qa): prove dirty worktree retention outcome * chore(protocol): regenerate swift gateway models * fix(agents): harden worktree cleanup recovery Register run_end_cleanup_json as a lazy compatible column so same-version v6 index repair and read-only doctor migration can recover databases created before the column existed. Type removal contention at the registry boundary; unexpected claim failures now best-effort record a bounded failed outcome and rethrow the original error. * fix(ci): clear repo-wide lint debt blocking merge gates The red-main landing rule requires this PR to repair repository-wide merge-gate debt instead of bypassing it. Apply the current lint contracts mechanically and split turn-transition coverage into a concept-named sibling with per-file-safe test state. Exact line delta: +676/-574 (net +102) across 44 test/support files. * fix(ci): preserve cached health refresh proof Require the public refresh call to exist before accepting that sensitive fields were omitted, so the boundary proof cannot pass on a missing call. * fix(ci): correct test typing left by the lint sweep Literal-widened totalTokensVersion fixtures, a WebSocket RawData overload mismatch, and the protocol schema document cast broke check-test-types after the repo-wide lint repair. Aligns the fixtures with SessionEntry, narrows Buffer handling per RawData, and keeps the JSON-shaped undefined omission under structuredClone. * test(agents): reuse upstream resource-loader test support The session-loop split and #120463's helper extraction landed the same createResourceLoader/createCompactionHandlers twice; the rebase kept both, orphaning main's agent-session-loop-resource-loader.test-support.ts and failing the dead-code gate. Import the upstream helpers and delete the duplicates. * fix(agents): reject finalized rows at the worktree removal claim Address the accepted ClawSweeper late-claim finding by rereading and rejecting missing or finalized worktree rows inside the synchronous removal-claim transaction. Preserve the authoritative cleanup invariant: finalized contenders record nothing, while retained-busy is written only while the row remains live. * refactor(agents): reuse registry update for busy outcomes Keep the live-row conditional write in the canonical registry update path so the finalized-claim repair stays below the registry max-lines ratchet without weakening the authoritative-outcome invariant. * test(agents): drop session test duplicates after rebase Keep current main as the canonical owner of next-turn lifecycle coverage and correctness test support after replaying the older lint-debt split. * fix(agents): guard post-abort cleanup outcomes against finalization After abortWorktreeRemoval releases a stale remover's claim, its retained or failed write raced a finalizing remover and could overwrite the authoritative removed-lossless fact. Route every retained/failed write through the live-row condition; only the finalizing remover's own removed-lossless write stays unconditional. * fix(agents): persist the removal outcome atomically with finalization A delayed removed-lossless write after remove() finalized could race a restore plus newer cleanup and overwrite the newer operator-visible fact. The run-end outcome now rides remove()'s finalization update; every other cleanup write stays live-row conditional, so no post-finalize write path remains. * test(qa): restore strict cached-health contract assertions The lint sweep's Boolean() coercions let truthy non-booleans satisfy the wire-typed cached-meta contract. Assert the literal boolean for unknown-typed fields and use nullish-coalesced strict equivalents for boolean chains. * fix(agents): clear the stale cleanup outcome when restoring a worktree A restored checkout begins a new lifecycle; leaving the removed-lossless fact on the live row showed operators a stale result until the next cleanup. Restore clears the recorded outcome and the regression asserts the cleared state before the next cleanup records fresh truth. * fix(agents): scope stale cleanup outcomes to their observed lifecycle A stale remover's retained/failed write raced a concurrent remove-plus- restore: the revived row is live again, so the live-row condition alone could stamp a prior-lifecycle outcome. Condition those writes on the activity stamp the remover observed; restore bumps lastActiveAt, making any prior-lifecycle write a no-op. * fix(agents): advance the restore activity stamp within one millisecond Stale cleanup writes fence on the activity stamp they observed; a restore completing in the same millisecond could revive the row with an identical stamp and let the fence match. Restore now always advances past the stored value, and the ABA regression pins the clock to prove the same-millisecond case. --- .../OpenClawProtocol/GatewayModels.swift | 6 +- docs/concepts/managed-worktrees.md | 2 + .../gateway-protocol/src/schema/worktrees.ts | 21 ++ ...managed-worktrees-workboard-lifecycle.yaml | 8 +- src/agents/worktrees/registry.ts | 94 ++++++- .../worktrees/service.run-end-cleanup.test.ts | 235 ++++++++++++++++++ src/agents/worktrees/service.ts | 96 ++++++- src/agents/worktrees/types.ts | 15 ++ .../state-migrations.worktree-paths.test.ts | 17 +- src/state/openclaw-state-db-maintenance.ts | 1 + .../openclaw-state-db-schema-additive.ts | 1 + src/state/openclaw-state-db.generated.d.ts | 1 + src/state/openclaw-state-db.test.ts | 34 +++ src/state/openclaw-state-schema.sql | 1 + taxonomy.yaml | 2 +- .../image-generation-lifecycle.e2e.test.ts | 22 +- .../media/media-fetch-boundaries.e2e.test.ts | 14 +- .../runtime/agent-run-identity-inspection.ts | 41 ++- .../agent-run-identity-repeated-turn-child.ts | 2 +- .../agent-session-dedup-reconnect.e2e.test.ts | 14 +- ...agent-session-scope-continuity.e2e.test.ts | 9 +- .../agent-session-streaming.e2e.test.ts | 14 +- .../agent-tool-safety-approvals.e2e.test.ts | 4 +- .../cached-health-snapshot-boundaries.ts | 20 +- ...hannel-health-monitor-lifecycle-runtime.ts | 4 +- ...eway-client-transport-defaults.e2e.test.ts | 2 +- .../gateway-exec-approvals.e2e.test.ts | 4 +- .../runtime/gateway-hosted-web.e2e.test.ts | 9 +- .../gateway-node-control-plane.e2e.test.ts | 5 +- .../gateway-node-exec-approvals.e2e.test.ts | 7 +- .../gateway-node-pending-work.e2e.test.ts | 5 +- .../runtime/gateway-protocol-artifacts.ts | 61 ++--- .../runtime/gateway-rpc-account-health.ts | 24 +- .../gateway-rpc-automation.e2e.test.ts | 11 +- .../e2e/qa-lab/runtime/gateway-ssh-tunnels.ts | 4 +- .../runtime/gateway-stability-runtime.ts | 3 +- .../runtime/gateway-support-export-runtime.ts | 3 +- .../e2e/qa-lab/runtime/gateway-tls-pinning.ts | 6 +- ...ed-worktrees-cli-product-proof.e2e.test.ts | 8 +- ...kboard-lifecycle-product-proof.e2e.test.ts | 108 +++++++- ...penclaw-exec-process-lifecycle.e2e.test.ts | 4 +- .../runtime/otel-gateway-runtime.e2e.test.ts | 6 +- test/e2e/qa-lab/runtime/otel-test-support.ts | 2 +- ...n-transcript-cli-product-proof.e2e.test.ts | 8 +- .../qa-lab/tui/tui-pty-evidence-producer.ts | 3 +- test/external-script-modules.d.ts | 2 +- test/helpers/openclaw-test-instance.test.ts | 23 +- test/helpers/openclaw-test-instance.ts | 4 +- ...sions-transcripts-flip-proof-assertions.ts | 2 +- .../sqlite-sessions-transcripts-flip-proof.ts | 13 +- test/scripts/bench-agent-concurrency.test.ts | 4 +- .../scripts/bench-gateway-concurrency.test.ts | 2 +- .../ensure-playwright-chromium.test.ts | 24 +- test/scripts/managed-child-process.test.ts | 4 +- .../npm-placeholder-publication.test.ts | 7 +- test/scripts/openclaw-live-updater.test.ts | 4 +- test/scripts/run-oxlint.test.ts | 4 +- 57 files changed, 845 insertions(+), 209 deletions(-) create mode 100644 src/agents/worktrees/service.run-end-cleanup.test.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 3d19f5e92cf8..73c0546aaaad 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2948,6 +2948,7 @@ public struct WorktreeRecord: Codable, Sendable { public let createdat: Int public let lastactiveat: Int public let removedat: Int? + public let runendcleanup: AnyCodable? public init( id: String, @@ -2962,7 +2963,8 @@ public struct WorktreeRecord: Codable, Sendable { snapshotref: String? = nil, createdat: Int, lastactiveat: Int, - removedat: Int? = nil) + removedat: Int? = nil, + runendcleanup: AnyCodable? = nil) { self.id = id self.name = name @@ -2977,6 +2979,7 @@ public struct WorktreeRecord: Codable, Sendable { self.createdat = createdat self.lastactiveat = lastactiveat self.removedat = removedat + self.runendcleanup = runendcleanup } private enum CodingKeys: String, CodingKey { @@ -2993,6 +2996,7 @@ public struct WorktreeRecord: Codable, Sendable { case createdat = "createdAt" case lastactiveat = "lastActiveAt" case removedat = "removedAt" + case runendcleanup = "runEndCleanup" } } diff --git a/docs/concepts/managed-worktrees.md b/docs/concepts/managed-worktrees.md index 4f39764e7747..579660c68f98 100644 --- a/docs/concepts/managed-worktrees.md +++ b/docs/concepts/managed-worktrees.md @@ -68,6 +68,8 @@ OpenClaw applies these cleanup rules: - Snapshot records remain restorable for 30 days. Cleanup then deletes the snapshot ref and registry row. - A live OpenClaw process lock and any foreign or unrecognized git worktree lock protect a worktree from garbage collection. +Run-end cleanup records its outcome on the worktree record: lossless removal, retention because the checkout is busy, dirty, unpushed, or has provisioned-file drift, or failure with an error reason. Inspect the recorded outcome with `openclaw worktrees list --json` or `worktrees.list`. + Restore recreates `openclaw/` at the original pre-snapshot commit, then rebuilds the snapshot differences as unstaged modifications and untracked files. This keeps the synthetic snapshot commit out of branch history. The snapshot ref remains recorded as provenance. ## CLI diff --git a/packages/gateway-protocol/src/schema/worktrees.ts b/packages/gateway-protocol/src/schema/worktrees.ts index c97c00dfbe0e..05fc225e6580 100644 --- a/packages/gateway-protocol/src/schema/worktrees.ts +++ b/packages/gateway-protocol/src/schema/worktrees.ts @@ -5,6 +5,26 @@ import { NonEmptyString } from "./primitives.js"; const WorktreeNameSchema = Type.String({ pattern: "^[a-z0-9][a-z0-9-]{0,63}$" }); +const WorktreeRunEndCleanupSchema = Type.Union([ + closedObject({ + outcome: Type.String({ + enum: [ + "removed-lossless", + "retained-busy", + "retained-dirty", + "retained-unpushed", + "retained-provisioned-drift", + ], + }), + at: Type.Integer({ minimum: 0 }), + }), + closedObject({ + outcome: Type.Literal("failed"), + at: Type.Integer({ minimum: 0 }), + reason: Type.String({ minLength: 1, maxLength: 500 }), + }), +]); + export const WorktreeRecordSchema = closedObject({ id: NonEmptyString, name: WorktreeNameSchema, @@ -19,6 +39,7 @@ export const WorktreeRecordSchema = closedObject({ createdAt: Type.Integer({ minimum: 0 }), lastActiveAt: Type.Integer({ minimum: 0 }), removedAt: Type.Optional(Type.Integer({ minimum: 0 })), + runEndCleanup: Type.Optional(WorktreeRunEndCleanupSchema), }); export const WorktreesListParamsSchema = closedObject({}); diff --git a/qa/scenarios/runtime/managed-worktrees-workboard-lifecycle.yaml b/qa/scenarios/runtime/managed-worktrees-workboard-lifecycle.yaml index c7d106dcb0dc..6b9aa77d145f 100644 --- a/qa/scenarios/runtime/managed-worktrees-workboard-lifecycle.yaml +++ b/qa/scenarios/runtime/managed-worktrees-workboard-lifecycle.yaml @@ -7,20 +7,22 @@ scenario: coverage: primary: - agent-runtime.managed-worktrees-workboard-lifecycle - objective: Prove the real child gateway materializes a Workboard card workspace as a managed wb- worktree, writes the resolved checkout and branch back to the card, runs its subagent, and removes the clean checkout losslessly at run end. + objective: Prove the real child gateway materializes Workboard card workspaces as managed wb- worktrees, writes the resolved checkout and branch back to each card, runs their subagents, removes a clean checkout losslessly, and retains a dirty checkout with a recorded cleanup outcome. successCriteria: - Dispatch materializes the card's source workspace under the managed state directory as wb- on branch openclaw/wb-. - The dispatched card persists the resolved managed checkout path and branch while retaining its source workspace metadata. - - The real mock-provider subagent run reaches a terminal outcome and run-end cleanup removes the clean checkout losslessly. + - A real mock-provider subagent run reaches a terminal outcome, and run-end cleanup removes its clean checkout losslessly with a recorded removed-lossless outcome. + - A second real subagent run leaves an untracked file, and run-end cleanup records retained-dirty while keeping the live checkout intact. docsRefs: - docs/concepts/managed-worktrees.md - docs/concepts/qa-e2e-automation.md codeRefs: - src/agents/worktrees/service.ts + - src/agents/worktrees/service.run-end-cleanup.test.ts - extensions/workboard/src/dispatcher.ts - extensions/workboard/src/dispatcher-workspace.ts - test/e2e/qa-lab/runtime/managed-worktrees-workboard-lifecycle-product-proof.e2e.test.ts execution: kind: vitest path: test/e2e/qa-lab/runtime/managed-worktrees-workboard-lifecycle-product-proof.e2e.test.ts - summary: Run the real child gateway through Workboard card worktree materialization, workspace writeback, subagent completion, and run-end lossless removal. + summary: Run the real child gateway through Workboard card worktree materialization, workspace writeback, subagent completion, clean removal, and dirty retention with recorded cleanup outcomes. diff --git a/src/agents/worktrees/registry.ts b/src/agents/worktrees/registry.ts index aceb9aaa45b3..631fdeede97f 100644 --- a/src/agents/worktrees/registry.ts +++ b/src/agents/worktrees/registry.ts @@ -1,4 +1,5 @@ import type { DatabaseSync } from "node:sqlite"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { Insertable, Selectable } from "kysely"; import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js"; import { isLockOwnerDefinitelyStale } from "../../infra/stale-lock-file.js"; @@ -12,6 +13,7 @@ import { import type { ManagedWorktreeOwnerKind, ManagedWorktreeRecord, + ManagedWorktreeRunEndCleanup, ProvisionedFileState, } from "./types.js"; @@ -40,7 +42,41 @@ function kyselyLeaseFor(db: DatabaseSync) { return getNodeSqliteKysely(db); } +function parseRunEndCleanup( + raw: string | null | undefined, +): ManagedWorktreeRunEndCleanup | undefined { + if (raw == null) { + return undefined; + } + try { + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed) || !Number.isInteger(parsed.at) || (parsed.at as number) < 0) { + return undefined; + } + const at = parsed.at as number; + switch (parsed.outcome) { + case "failed": + return typeof parsed.reason === "string" && + parsed.reason.length > 0 && + parsed.reason.length <= 500 + ? { outcome: parsed.outcome, at, reason: parsed.reason } + : undefined; + case "removed-lossless": + case "retained-busy": + case "retained-dirty": + case "retained-unpushed": + case "retained-provisioned-drift": + return parsed.reason === undefined ? { outcome: parsed.outcome, at } : undefined; + default: + return undefined; + } + } catch { + return undefined; + } +} + function rowToRecord(row: WorktreeRow): ManagedWorktreeRecord { + const runEndCleanup = parseRunEndCleanup(row.run_end_cleanup_json); return { id: row.id, name: row.path.split(/[\\/]/).at(-1) ?? row.id, @@ -55,6 +91,7 @@ function rowToRecord(row: WorktreeRow): ManagedWorktreeRecord { createdAt: row.created_at, lastActiveAt: row.last_active_at, ...(row.removed_at == null ? {} : { removedAt: row.removed_at }), + ...(runEndCleanup ? { runEndCleanup } : {}), }; } @@ -77,6 +114,8 @@ function recordToRow( removed_at: record.removedAt ?? null, provisioned_paths_json: provisionedPaths === undefined ? null : JSON.stringify(provisionedPaths), + run_end_cleanup_json: + record.runEndCleanup === undefined ? null : JSON.stringify(record.runEndCleanup), }; } @@ -356,10 +395,13 @@ export function insertRegistryWorktree( export function updateRegistryWorktree( env: NodeJS.ProcessEnv, id: string, - patch: Partial> & { + patch: Partial< + Pick + > & { provisionedPaths?: readonly string[]; provisionedState?: readonly ProvisionedFileState[]; }, + options: { onlyIfLive?: boolean; onlyIfActiveAt?: number } = {}, ): void { const db = dbFor(env); const values: Partial = {}; @@ -372,16 +414,28 @@ export function updateRegistryWorktree( if ("snapshotRef" in patch) { values.snapshot_ref = patch.snapshotRef ?? null; } + if ("runEndCleanup" in patch) { + values.run_end_cleanup_json = + patch.runEndCleanup === undefined ? null : JSON.stringify(patch.runEndCleanup); + } if (patch.provisionedState !== undefined) { values.provisioned_paths_json = JSON.stringify(patch.provisionedState); } else if (patch.provisionedPaths !== undefined) { values.provisioned_paths_json = JSON.stringify(patch.provisionedPaths); } runOpenClawStateWriteTransaction(() => { - executeSqliteQuerySync( - db, - kyselyFor(db).updateTable("worktrees").set(values).where("id", "=", id), - ); + let update = kyselyFor(db).updateTable("worktrees").set(values).where("id", "=", id); + // Busy/retained/failed outcomes are authoritative only for the lifecycle the + // writer observed: the live condition blocks post-finalization overwrites, and + // the activity condition blocks prior-lifecycle writes after a concurrent + // remove-plus-restore revives the row (restore bumps last_active_at). + if (options.onlyIfLive) { + update = update.where("removed_at", "is", null); + } + if (options.onlyIfActiveAt !== undefined) { + update = update.where("last_active_at", "=", options.onlyIfActiveAt); + } + executeSqliteQuerySync(db, update); }); } @@ -401,6 +455,16 @@ export function deleteRegistryWorktree(env: NodeJS.ProcessEnv, id: string): void const WORKTREE_RUN_LEASE_SCOPE_PREFIX = "worktree-run:"; const WORKTREE_REMOVING_LEASE_KEY = "__removing__"; +export class WorktreeRemovalContentionError extends Error { + constructor( + readonly kind: "busy" | "finalized", + message: string, + ) { + super(message); + this.name = "WorktreeRemovalContentionError"; + } +} + export type RunLeaseOwnerChecks = { isPidDefinitelyDead?: (pid: number) => boolean; getProcessStartTime?: (pid: number) => number | null; @@ -547,14 +611,30 @@ export function claimWorktreeRemovalRow( const db = database.db; const k = kyselyLeaseFor(db); const scope = worktreeRunLeaseScope(params.worktreeId); + const record = executeSqliteQuerySync( + db, + k + .selectFrom("worktrees") + .select(["id", "path", "removed_at"]) + .where("id", "=", params.worktreeId), + ).rows[0]; + if (!record || record.removed_at != null) { + throw new WorktreeRemovalContentionError( + "finalized", + `managed worktree was removed: ${record?.path ?? params.worktreeId}`, + ); + } const { livePids, removingToken } = collectLiveRunLeases(db, k, scope, params.checks ?? {}); if (!params.force && livePids.length > 0) { - throw new Error(`worktree is busy: locked by live pid ${livePids[0]}`); + throw new WorktreeRemovalContentionError( + "busy", + `worktree is busy: locked by live pid ${livePids[0]}`, + ); } // The removal claim is exclusive: a live marker owned by a different token means // another remover is mid-operation, so this remover must not enter it too. if (removingToken !== undefined && removingToken !== params.token) { - throw new Error("worktree removal is already in progress"); + throw new WorktreeRemovalContentionError("busy", "worktree removal is already in progress"); } const payloadJson = JSON.stringify({ pid: params.pid, diff --git a/src/agents/worktrees/service.run-end-cleanup.test.ts b/src/agents/worktrees/service.run-end-cleanup.test.ts new file mode 100644 index 000000000000..0c23b5c3dbe4 --- /dev/null +++ b/src/agents/worktrees/service.run-end-cleanup.test.ts @@ -0,0 +1,235 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; +import { + getRegistryWorktree, + updateRegistryWorktree, + WorktreeRemovalContentionError, +} from "./registry.js"; +import { acquireWorktreeRunLease, claimWorktreeRemoval } from "./run-lease.js"; +import { testing as runLeaseTesting } from "./run-lease.test-support.js"; +import { ManagedWorktreeService } from "./service.js"; +import { + initializeManagedWorktreeTestRepository, + materializeManagedWorktreeFixture, +} from "./service.test-support.js"; + +const execFileAsync = promisify(execFile); + +async function git(cwd: string, ...args: string[]): Promise { + await execFileAsync("git", ["-C", cwd, ...args]); +} + +describe("ManagedWorktreeService run-end cleanup outcomes", () => { + let root: string; + let repo: string; + let stateDir: string; + let env: NodeJS.ProcessEnv; + let service: ManagedWorktreeService; + const now = 1_700_000_000_000; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(await fs.realpath(os.tmpdir()), "openclaw-run-end-cleanup-")); + repo = await initializeManagedWorktreeTestRepository(root); + stateDir = path.join(root, "state"); + env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + service = new ManagedWorktreeService({ env, now: () => now }); + }); + + afterEach(async () => { + runLeaseTesting.resetForTest(); + closeOpenClawStateDatabaseForTest(); + await fs.rm(root, { recursive: true, force: true }); + }); + + async function materialize(name: string) { + return await materializeManagedWorktreeFixture({ + env, + name, + now, + ownerKind: "workboard", + ownerId: `card-${name}`, + repoRoot: repo, + stateDir, + }); + } + + it("records removal after clean run-end cleanup", async () => { + const created = await materialize("clean"); + await service.acquire(created.id); + + await expect(service.removeIfLossless(created.id)).resolves.toBe(true); + + expect(getRegistryWorktree(env, created.id)).toMatchObject({ + removedAt: now, + runEndCleanup: { outcome: "removed-lossless", at: now }, + }); + await expect(fs.access(created.path)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("preserves the winning removal outcome when a stale remover claims late", async () => { + const created = await materialize("late-claim"); + await service.acquire(created.id); + const staleRecord = getRegistryWorktree(env, created.id)!; + + await expect(service.removeIfLossless(created.id)).resolves.toBe(true); + + let contention: unknown; + try { + claimWorktreeRemoval(env, { + worktreeId: staleRecord.id, + token: "late-remover", + force: false, + }); + } catch (error) { + contention = error; + } + expect(contention).toBeInstanceOf(WorktreeRemovalContentionError); + expect(contention).toMatchObject({ kind: "finalized" }); + expect(getRegistryWorktree(env, created.id)).toMatchObject({ + removedAt: now, + runEndCleanup: { outcome: "removed-lossless", at: now }, + }); + }); + + it("keeps the winning removal outcome when a post-abort write lands after finalization", async () => { + const created = await materialize("post-abort-race"); + await service.acquire(created.id); + await expect(service.removeIfLossless(created.id)).resolves.toBe(true); + + // A stale remover that aborted its claim writes retained/failed outcomes with + // the live-row condition (recordOutcome); against a finalized row it must be + // a no-op instead of replacing the winner's removed-lossless fact. + updateRegistryWorktree( + env, + created.id, + { runEndCleanup: { outcome: "retained-dirty", at: now + 1 } }, + { onlyIfLive: true }, + ); + + expect(getRegistryWorktree(env, created.id)).toMatchObject({ + removedAt: now, + runEndCleanup: { outcome: "removed-lossless", at: now }, + }); + }); + + it("lets a newer post-restore cleanup outcome supersede the removal fact", async () => { + const created = await materialize("restore-generation"); + await service.acquire(created.id); + await expect(service.removeIfLossless(created.id)).resolves.toBe(true); + expect(getRegistryWorktree(env, created.id)?.runEndCleanup).toMatchObject({ + outcome: "removed-lossless", + }); + + const restored = await service.restore({ id: created.id }); + // Restore starts a new lifecycle: the stale removal outcome must not show + // on the now-live row. + expect(restored.runEndCleanup).toBeUndefined(); + expect(getRegistryWorktree(env, created.id)?.runEndCleanup).toBeUndefined(); + await fs.writeFile(path.join(restored.path, "untracked.txt"), "retain me\n"); + await service.acquire(created.id); + await expect(service.removeIfLossless(created.id)).resolves.toBe(false); + + expect(getRegistryWorktree(env, created.id)).toMatchObject({ + runEndCleanup: { outcome: "retained-dirty", at: now }, + }); + }); + + it("drops a prior-lifecycle outcome write after a concurrent remove and restore", async () => { + const created = await materialize("aba-restore-race"); + const staleActiveAt = created.lastActiveAt; + await service.acquire(created.id); + await expect(service.removeIfLossless(created.id)).resolves.toBe(true); + // The pinned clock makes remove and restore share one millisecond — the + // exact case where restore must still advance the activity stamp so the + // stale writer's fence cannot match. + const restored = await service.restore({ id: created.id }); + expect(restored.lastActiveAt).toBe(staleActiveAt + 1); + + // A stale remover from the pre-restore lifecycle writes with the activity + // stamp it observed (recordOutcome's condition); against the revived row it + // must be a no-op instead of stamping a prior-lifecycle outcome. + updateRegistryWorktree( + env, + created.id, + { runEndCleanup: { outcome: "retained-dirty", at: now + 1 } }, + { onlyIfLive: true, onlyIfActiveAt: staleActiveAt }, + ); + + expect(getRegistryWorktree(env, created.id)?.runEndCleanup).toBeUndefined(); + }); + + it("records dirty retention and keeps the checkout intact", async () => { + const created = await materialize("dirty"); + await service.acquire(created.id); + const dirtyFile = path.join(created.path, "untracked.txt"); + await fs.writeFile(dirtyFile, "retain me\n"); + + await expect(service.removeIfLossless(created.id)).resolves.toBe(false); + + const retained = getRegistryWorktree(env, created.id); + expect(retained).toMatchObject({ + runEndCleanup: { outcome: "retained-dirty", at: now }, + }); + expect(retained?.removedAt).toBeUndefined(); + await expect(fs.readFile(dirtyFile, "utf8")).resolves.toBe("retain me\n"); + }); + + it("records unpushed retention", async () => { + const created = await materialize("unpushed"); + await service.acquire(created.id); + await fs.writeFile(path.join(created.path, "committed.txt"), "unpushed\n"); + await git(created.path, "add", "committed.txt"); + await git(created.path, "commit", "-m", "unpushed worktree commit"); + + await expect(service.removeIfLossless(created.id)).resolves.toBe(false); + + const retained = getRegistryWorktree(env, created.id); + expect(retained).toMatchObject({ + runEndCleanup: { outcome: "retained-unpushed", at: now }, + }); + expect(retained?.removedAt).toBeUndefined(); + await expect(fs.access(created.path)).resolves.toBeUndefined(); + }); + + it("records busy retention while a run lease is live", async () => { + const created = await materialize("busy"); + const lease = await acquireWorktreeRunLease(created.id, { env }); + + await expect(service.removeIfLossless(created.id)).resolves.toBe(false); + + const retained = getRegistryWorktree(env, created.id); + expect(retained).toMatchObject({ + runEndCleanup: { outcome: "retained-busy", at: now }, + }); + expect(retained?.removedAt).toBeUndefined(); + await expect(fs.access(created.path)).resolves.toBeUndefined(); + await lease.release(); + }); + + it("records and rethrows an unexpected removal claim failure", async () => { + const created = await materialize("claim-failure"); + const lease = await acquireWorktreeRunLease(created.id, { env }); + const failure = new Error("synthetic removal claim failure"); + runLeaseTesting.setDeadPidResolverForTest(() => { + throw failure; + }); + + await expect(service.removeIfLossless(created.id)).rejects.toBe(failure); + + expect(getRegistryWorktree(env, created.id)).toMatchObject({ + runEndCleanup: { + outcome: "failed", + at: now, + reason: "synthetic removal claim failure", + }, + }); + await expect(fs.access(created.path)).resolves.toBeUndefined(); + runLeaseTesting.setDeadPidResolverForTest(null); + await lease.release(); + }); +}); diff --git a/src/agents/worktrees/service.ts b/src/agents/worktrees/service.ts index 46ea0f669f8a..89b355e08b4d 100644 --- a/src/agents/worktrees/service.ts +++ b/src/agents/worktrees/service.ts @@ -3,8 +3,10 @@ import type { Dirent } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { resolveStateDir } from "../../config/paths.js"; import { isMissingPathError } from "../../infra/errors.js"; +import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { runCommandWithTimeout } from "../../process/exec.js"; import { withOpenClawStateLease } from "../../state/openclaw-state-lease.js"; @@ -42,6 +44,7 @@ import { insertRegistryWorktree, listRegistryWorktrees, updateRegistryWorktree, + WorktreeRemovalContentionError, } from "./registry.js"; import { abortWorktreeRemoval, @@ -56,6 +59,8 @@ import type { ManagedWorktreeGcResult, ManagedWorktreeOwnerKind, ManagedWorktreeRecord, + ManagedWorktreeRunEndCleanup, + ManagedWorktreeRunEndCleanupOutcome, RemoveManagedWorktreeResult, } from "./types.js"; @@ -905,6 +910,7 @@ export class ManagedWorktreeService { reason: string; force?: boolean; claimToken?: string; + runEndCleanup?: ManagedWorktreeRunEndCleanup; }): Promise { const record = this.requireLiveRecord(params.id); const force = params.force ?? false; @@ -969,7 +975,13 @@ export class ManagedWorktreeService { await requireGit(record.repoRoot, ["worktree", "prune"]); await removeEmptyParents(path.dirname(record.path), await this.worktreesRoot()); const removedAt = this.now(); - updateRegistryWorktree(this.env, record.id, { removedAt, snapshotRef }); + // Persist the run-end outcome atomically with finalization: a post-finalize + // write could race a restore plus newer cleanup and overwrite the newer fact. + updateRegistryWorktree(this.env, record.id, { + removedAt, + snapshotRef, + ...(params.runEndCleanup ? { runEndCleanup: params.runEndCleanup } : {}), + }); finalizeWorktreeRemoval(this.env, record.id); return { removed: true, @@ -1026,29 +1038,75 @@ export class ManagedWorktreeService { } throw error; } - const lastActiveAt = this.now(); + // Advance past the stored stamp even within the same millisecond: stale + // cleanup writes fence on the activity stamp they observed, so a restore + // must never revive the row with an identical value. + const lastActiveAt = Math.max(this.now(), record.lastActiveAt + 1); updateRegistryWorktree(this.env, params.id, { removedAt: undefined, lastActiveAt, provisionedPaths: restoredProvisionedPaths, + // The recorded cleanup outcome described the removed lifecycle; a restored + // checkout starts a new one, so a stale removed-lossless must not show on + // a live row until the next run-end cleanup records fresh truth. + runEndCleanup: undefined, }); // Clear any lease rows or removal marker stranded by a crash between git removal // and finalize so the restored worktree admits runs again. finalizeWorktreeRemoval(this.env, params.id); const restored = { ...record, lastActiveAt }; delete restored.removedAt; + delete restored.runEndCleanup; return restored; } async removeIfLossless(id: string): Promise { const record = this.requireLiveRecord(id); const claimToken = randomUUID(); + const recordOutcome = (outcome: ManagedWorktreeRunEndCleanupOutcome, error?: unknown) => { + // Retained/failed writes happen after this remover released or aborted its + // claim, so racing removers may have finalized the row, or removed AND + // restored it into a new lifecycle. The live condition blocks the first; + // conditioning on the activity stamp this remover observed blocks the + // second (restore bumps lastActiveAt). The winning removal persists its + // outcome atomically inside remove()'s finalization update, never here. + updateRegistryWorktree( + this.env, + id, + { + runEndCleanup: { + outcome, + at: this.now(), + ...(outcome === "failed" + ? { reason: truncateUtf16Safe(formatErrorMessage(error), 500) } + : {}), + }, + }, + { onlyIfLive: true, onlyIfActiveAt: record.lastActiveAt }, + ); + }; + // Run-end cleanup must leave a durable outcome even when safety retains the checkout. + // QA and operators observe this product-boundary fact through worktrees.list. try { claimWorktreeRemoval(this.env, { worktreeId: id, token: claimToken, force: false }); - } catch { - // A live run lease or a competing remover holds the worktree; a lossless - // auto-cleanup must not race it. - return false; + } catch (error) { + if (error instanceof WorktreeRemovalContentionError) { + if (error.kind === "finalized") { + // The winning remover owns the terminal cleanup fact; a late contender + // must return without replacing it with a false retained/failed outcome. + return false; + } + // A live run lease or a competing remover holds the worktree; a lossless + // auto-cleanup must not race it. + recordOutcome("retained-busy"); + return false; + } + try { + recordOutcome("failed", error); + } catch { + // Preserve the claim failure when the same infrastructure blocks recording it. + } + throw error; } try { const status = await requireGit(record.path, ["status", "--porcelain"]); @@ -1063,16 +1121,36 @@ export class ManagedWorktreeService { record.path, getRegistryWorktreeProvisionedPaths(this.env, record.id), ); - if (status || unpushed || ignoredDrift) { + const retainedOutcome = status + ? "retained-dirty" + : unpushed + ? "retained-unpushed" + : ignoredDrift + ? "retained-provisioned-drift" + : undefined; + if (retainedOutcome) { abortWorktreeRemoval(this.env, id, claimToken); + recordOutcome(retainedOutcome); return false; } } catch (error) { abortWorktreeRemoval(this.env, id, claimToken); + recordOutcome("failed", error); + throw error; + } + try { + await this.release(id); + await this.remove({ + id, + reason: "run-end", + claimToken, + runEndCleanup: { outcome: "removed-lossless", at: this.now() }, + }); + } catch (error) { + abortWorktreeRemoval(this.env, id, claimToken); + recordOutcome("failed", error); throw error; } - await this.release(id); - await this.remove({ id, reason: "run-end", claimToken }); return true; } diff --git a/src/agents/worktrees/types.ts b/src/agents/worktrees/types.ts index 5e728953c9d2..00c5739a1e27 100644 --- a/src/agents/worktrees/types.ts +++ b/src/agents/worktrees/types.ts @@ -1,5 +1,19 @@ export type ManagedWorktreeOwnerKind = "manual" | "workboard" | "session"; +export type ManagedWorktreeRunEndCleanupOutcome = + | "removed-lossless" + | "retained-busy" + | "retained-dirty" + | "retained-unpushed" + | "retained-provisioned-drift" + | "failed"; + +export type ManagedWorktreeRunEndCleanup = { + outcome: ManagedWorktreeRunEndCleanupOutcome; + at: number; + reason?: string; +}; + export type ProvisionedFileState = { path: string; mode: number | null; @@ -20,6 +34,7 @@ export type ManagedWorktreeRecord = { createdAt: number; lastActiveAt: number; removedAt?: number; + runEndCleanup?: ManagedWorktreeRunEndCleanup; }; export type CreateManagedWorktreeParams = { diff --git a/src/infra/state-migrations.worktree-paths.test.ts b/src/infra/state-migrations.worktree-paths.test.ts index d9ee66088f00..a31674ca2021 100644 --- a/src/infra/state-migrations.worktree-paths.test.ts +++ b/src/infra/state-migrations.worktree-paths.test.ts @@ -11,6 +11,7 @@ import { closeOpenClawStateDatabaseForTest, openOpenClawStateDatabase, } from "../state/openclaw-state-db.js"; +import { requireNodeSqlite } from "./node-sqlite.js"; import { detectLegacyStateMigrations, runLegacyStateMigrations } from "./state-migrations.js"; describe("managed worktree path state migrations", () => { @@ -43,7 +44,7 @@ describe("managed worktree path state migrations", () => { }); it.skipIf(process.platform === "win32")( - "canonicalizes persisted paths from symlinked state directories", + "canonicalizes persisted paths when the latest additive worktree column is absent", async () => { const root = tempDirs.make( "openclaw-worktree-path-migration-", @@ -65,7 +66,8 @@ describe("managed worktree path state migrations", () => { live.repoFingerprint, "removed", ); - const db = openOpenClawStateDatabase({ env }).db; + const database = openOpenClawStateDatabase({ env }); + const db = database.db; db.prepare("UPDATE worktrees SET path = ? WHERE id = ?").run(rawLivePath, live.id); const removed = { ...live, @@ -94,7 +96,18 @@ describe("managed worktree path state migrations", () => { insertRegistryWorktree(env, canonical, { provisionedPaths: [] }); insertRegistryWorktree(env, moved, { provisionedPaths: [] }); + closeOpenClawStateDatabaseForTest(); + const { DatabaseSync } = requireNodeSqlite(); + const beforeCleanupOutcome = new DatabaseSync(database.path); + try { + beforeCleanupOutcome.exec("ALTER TABLE worktrees DROP COLUMN run_end_cleanup_json;"); + } finally { + beforeCleanupOutcome.close(); + } + const cfg = {} as OpenClawConfig; + // Doctor's read-only SELECT * follows the physical columns. Compatibility + // validation must allow this additive column to be absent before that query. const detected = await detectLegacyStateMigrations({ cfg, env, homedir: () => root }); expect(detected.preview).toContain( "- Managed worktrees: canonicalize 2 persisted paths for symlinked state directories", diff --git a/src/state/openclaw-state-db-maintenance.ts b/src/state/openclaw-state-db-maintenance.ts index 89f7a6f10271..992f78a20c13 100644 --- a/src/state/openclaw-state-db-maintenance.ts +++ b/src/state/openclaw-state-db-maintenance.ts @@ -32,6 +32,7 @@ export const CLAW_LAZY_ADDITIVE_STATE_COLUMNS = [ "claw_package_refs.extension_id", "claw_package_refs.extension_mapped_json", "claw_package_refs.extension_unavailable_json", + "worktrees.run_end_cleanup_json", ] as const; const OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY = { diff --git a/src/state/openclaw-state-db-schema-additive.ts b/src/state/openclaw-state-db-schema-additive.ts index b8dc4294f36b..1a460a0cdc15 100644 --- a/src/state/openclaw-state-db-schema-additive.ts +++ b/src/state/openclaw-state-db-schema-additive.ts @@ -138,6 +138,7 @@ export function ensureAdditiveStateColumns(db: DatabaseSync): void { } db.exec("DROP INDEX IF EXISTS idx_diagnostic_events_scope_created;"); ensureColumn(db, "worktrees", "provisioned_paths_json TEXT"); + ensureColumn(db, "worktrees", "run_end_cleanup_json TEXT"); ensureColumn(db, "node_host_config", "gateway_context_path TEXT"); ensureColumn(db, "node_host_config", "installed_apps_sharing INTEGER NOT NULL DEFAULT 0"); ensureColumn(db, "apns_registrations", "relay_origin TEXT"); diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index 53f92e6445d5..128f4484f3af 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -1573,6 +1573,7 @@ export interface Worktrees { removed_at: number | null; repo_fingerprint: string; repo_root: string; + run_end_cleanup_json: string | null; snapshot_ref: string | null; } diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index 3f2dca5a4f0f..1b1ec9d417e5 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -1956,6 +1956,40 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're ).toEqual([{ task_id: "task-index-repair" }]); }); + it("repairs the same-version worktree cleanup column with physical index drift", () => { + const stateDir = createTempStateDir(); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const databasePath = materializeCurrentStateDatabase(stateDir); + + const { DatabaseSync } = requireNodeSqlite(); + const shippedSchema = new DatabaseSync(databasePath); + try { + shippedSchema.exec("ALTER TABLE worktrees DROP COLUMN run_end_cleanup_json;"); + expect(readSqliteNumberPragma(shippedSchema, "user_version")).toBe( + OPENCLAW_STATE_SCHEMA_VERSION, + ); + } finally { + shippedSchema.close(); + } + createTaskRunStatusIndexPhysicalDrift(databasePath); + + const reopened = openOpenClawStateDatabase({ env }); + const columns = reopened.db.prepare("PRAGMA table_info(worktrees)").all() as Array<{ + name: string; + }>; + expect(columns.map((column) => column.name)).toContain("run_end_cleanup_json"); + expect(reopened.db.prepare("PRAGMA integrity_check").get()).toEqual({ + integrity_check: "ok", + }); + expect( + reopened.db + .prepare( + "SELECT task_id FROM task_runs INDEXED BY idx_task_runs_status WHERE status = 'running'", + ) + .all(), + ).toEqual([{ task_id: "task-index-repair" }]); + }); + it("does not add Claw bootstrap columns before rejecting unrelated index corruption", () => { const stateDir = createTempStateDir(); const env = { OPENCLAW_STATE_DIR: stateDir }; diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 07f1414758dd..bacd10d0440d 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -1801,6 +1801,7 @@ CREATE TABLE IF NOT EXISTS worktrees ( owner_id TEXT, snapshot_ref TEXT, provisioned_paths_json TEXT, + run_end_cleanup_json TEXT, created_at INTEGER NOT NULL, last_active_at INTEGER NOT NULL, removed_at INTEGER diff --git a/taxonomy.yaml b/taxonomy.yaml index aebc800f24b2..6cd1c66f757a 100644 --- a/taxonomy.yaml +++ b/taxonomy.yaml @@ -1538,7 +1538,7 @@ surfaces: description: Session worktree creation through sessions.create, persisted session worktree metadata, gc owner protection while the session is live, session-delete snapshot removal, and worktreePreserved reporting for locked checkouts. - name: "Managed worktrees — Workboard owner" coverageIds: [agent-runtime.managed-worktrees-workboard-lifecycle] - description: Workboard card workspace materialization as wb- worktrees, card workspace writeback, and run-end lossless removal. + description: Workboard card workspace materialization as wb- worktrees, card workspace writeback, run-end lossless removal, and dirty-checkout retention with a recorded cleanup outcome. docs: - docs/concepts/agent-loop.md - docs/cli/agent.md diff --git a/test/e2e/qa-lab/media/image-generation-lifecycle.e2e.test.ts b/test/e2e/qa-lab/media/image-generation-lifecycle.e2e.test.ts index 7edc2912aa74..1bb0562e781c 100644 --- a/test/e2e/qa-lab/media/image-generation-lifecycle.e2e.test.ts +++ b/test/e2e/qa-lab/media/image-generation-lifecycle.e2e.test.ts @@ -58,12 +58,12 @@ async function startControlledImageProvider() { ], }); }; - const server = createServer(async (request, response) => { - if (request.method !== "POST" || request.url !== "/v1/images/generations") { - writeJson(response, 404, { error: "not found" }); - return; - } - try { + const server = createServer((request, response) => { + void (async () => { + if (request.method !== "POST" || request.url !== "/v1/images/generations") { + writeJson(response, 404, { error: "not found" }); + return; + } const body = JSON.parse(await readRequestBody(request)) as Record; requests.push(body); if (released) { @@ -71,11 +71,11 @@ async function startControlledImageProvider() { return; } pendingResponses.add(response); - } catch (error) { + })().catch((error: unknown) => { writeJson(response, 400, { error: error instanceof Error ? error.message : String(error), }); - } + }); }); await new Promise((resolve, reject) => { server.once("error", reject); @@ -84,7 +84,7 @@ async function startControlledImageProvider() { const address = server.address() as AddressInfo; const release = () => { released = true; - for (const response of [...pendingResponses]) { + for (const response of pendingResponses) { complete(response); } }; @@ -317,7 +317,9 @@ describe("image generation task lifecycle through QA-channel", () => { expect.objectContaining({ id: taskId, status: "completed" }), ]); - await new Promise((resolve) => setTimeout(resolve, 500)); + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); const completionOutcomes = state .getSnapshot() .messages.filter( diff --git a/test/e2e/qa-lab/media/media-fetch-boundaries.e2e.test.ts b/test/e2e/qa-lab/media/media-fetch-boundaries.e2e.test.ts index 349feef64045..b35d447d4a9a 100644 --- a/test/e2e/qa-lab/media/media-fetch-boundaries.e2e.test.ts +++ b/test/e2e/qa-lab/media/media-fetch-boundaries.e2e.test.ts @@ -99,14 +99,12 @@ async function expectNoPublishedMediaFiles(stateDir: string): Promise { const files: string[] = []; const visit = async (directory: string): Promise => { - const entries = await fs - .readdir(directory, { withFileTypes: true }) - .catch((error: NodeJS.ErrnoException) => { - if (error.code === "ENOENT") { - return []; - } - throw error; - }); + const entries = await fs.readdir(directory, { withFileTypes: true }).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw error; + }); for (const entry of entries) { const entryPath = path.join(directory, entry.name); if (entry.isDirectory()) { diff --git a/test/e2e/qa-lab/runtime/agent-run-identity-inspection.ts b/test/e2e/qa-lab/runtime/agent-run-identity-inspection.ts index 50fca3d2ca14..4870cfbef578 100644 --- a/test/e2e/qa-lab/runtime/agent-run-identity-inspection.ts +++ b/test/e2e/qa-lab/runtime/agent-run-identity-inspection.ts @@ -55,7 +55,7 @@ async function updateExecutionIdentityConfig( values: { enabled?: boolean; executionIdentity: boolean }, ) { const raw = await fs.readFile(configPath, "utf8"); - const config = parseJson>(raw || "{}", "QA Gateway config"); + const config = parseJson(raw || "{}", "QA Gateway config") as Record; const logging = config.logging && typeof config.logging === "object" ? (config.logging as Record) @@ -83,11 +83,11 @@ function parseOptions(argv: readonly string[]): ProducerOptions { }; } -function parseJson(raw: string, label: string): T { +function parseJson(raw: string, label: string): unknown { try { - return JSON.parse(raw) as T; + return JSON.parse(raw) as unknown; } catch (error) { - throw new Error(`${label} was not JSON: ${formatErrorMessage(error)}`); + throw new Error(`${label} was not JSON: ${formatErrorMessage(error)}`, { cause: error }); } } @@ -158,10 +158,9 @@ function findLocalRunId(gateway: Awaited> ) .all() as Array<{ run_id: string; context_json: string }>; const localRows = rows.filter((row) => { - const context = parseJson<{ ingress?: { kind?: string } }>( - row.context_json, - "persisted local context", - ); + const context = parseJson(row.context_json, "persisted local context") as { + ingress?: { kind?: string }; + }; return context.ingress?.kind === "local-cli"; }); if (localRows.length !== 1 || !localRows[0]?.run_id) { @@ -318,10 +317,10 @@ async function runProof(options: ProducerOptions): Promise { const runId = findLocalRunId(gateway); const beforeText = await gateway.runCli(["audit", "--run", runId, "--explain"]); assertTextProjection(beforeText); - const before = parseJson( + const before = parseJson( await gateway.runCli(["audit", "--run", runId, "--explain", "--json"]), "pre-restart audit inspection", - ); + ) as AuditRunInspectResult; assertJsonProjection(before, runId); const beforeContext = normalizedContextJson(before); @@ -344,10 +343,10 @@ async function runProof(options: ProducerOptions): Promise { ) { throw new Error("ambiguous run discovery omitted exact-execution selection guidance"); } - const discovery = parseJson( + const discovery = parseJson( await gateway.runCli(["audit", "--run", repeatedRunId, "--explain", "--json"]), "repeated-run discovery", - ); + ) as AuditRunInspectResult; if (discovery.identity.state !== "ambiguous" || discovery.identity.candidates.length !== 2) { throw new Error("repeated same-session run was not reported as two ambiguous executions"); } @@ -355,10 +354,10 @@ async function runProof(options: ProducerOptions): Promise { for (const row of repeatedRows) { const text = await gateway.runCli(["audit", "--execution", row.execution_id, "--explain"]); assertTextProjection(text); - const exact = parseJson( + const exact = parseJson( await gateway.runCli(["audit", "--execution", row.execution_id, "--explain", "--json"]), `execution ${row.execution_id}`, - ); + ) as AuditRunInspectResult; const context = requireIdentityContext(exact); if ( exact.run.executionId !== row.execution_id || @@ -381,20 +380,20 @@ async function runProof(options: ProducerOptions): Promise { const afterText = await gateway.runCli(["audit", "--run", runId, "--explain"]); assertTextProjection(afterText); - const after = parseJson( + const after = parseJson( await gateway.runCli(["audit", "--run", runId, "--explain", "--json"]), "post-restart audit inspection", - ); + ) as AuditRunInspectResult; assertJsonProjection(after, runId); const afterContext = normalizedContextJson(after); if (afterContext !== beforeContext) { throw new Error("normalized execution identity context bytes changed across Gateway restart"); } for (const [executionId, expectedContext] of repeatedBeforeRestart) { - const afterExact = parseJson( + const afterExact = parseJson( await gateway.runCli(["audit", "--execution", executionId, "--explain", "--json"]), `post-restart execution ${executionId}`, - ); + ) as AuditRunInspectResult; if (normalizedContextJson(afterExact) !== expectedContext) { throw new Error(`repeated execution changed across Gateway restart: ${executionId}`); } @@ -410,10 +409,10 @@ async function runProof(options: ProducerOptions): Promise { if (inspectExecutionIdentityStorage(gateway).rowCount !== retainedBeforeGlobalDisable) { throw new Error("global audit disable unexpectedly retained a new execution context"); } - const afterGlobalDisable = parseJson( + const afterGlobalDisable = parseJson( await gateway.runCli(["audit", "--run", runId, "--explain", "--json"]), "global-disabled retained inspection", - ); + ) as AuditRunInspectResult; if (normalizedContextJson(afterGlobalDisable) !== beforeContext) { throw new Error("global audit disable hid or changed retained identity evidence"); } @@ -518,7 +517,7 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { .then((exitCode) => { process.exitCode = exitCode; }) - .catch((error) => { + .catch((error: unknown) => { console.error(formatErrorMessage(error)); process.exitCode = 1; }); diff --git a/test/e2e/qa-lab/runtime/agent-run-identity-repeated-turn-child.ts b/test/e2e/qa-lab/runtime/agent-run-identity-repeated-turn-child.ts index 07953cc98930..94e29f976f5d 100644 --- a/test/e2e/qa-lab/runtime/agent-run-identity-repeated-turn-child.ts +++ b/test/e2e/qa-lab/runtime/agent-run-identity-repeated-turn-child.ts @@ -38,7 +38,7 @@ async function main() { } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - main().catch((error) => { + main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }); diff --git a/test/e2e/qa-lab/runtime/agent-session-dedup-reconnect.e2e.test.ts b/test/e2e/qa-lab/runtime/agent-session-dedup-reconnect.e2e.test.ts index acf65b4cf6af..f68a728feb63 100644 --- a/test/e2e/qa-lab/runtime/agent-session-dedup-reconnect.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/agent-session-dedup-reconnect.e2e.test.ts @@ -128,9 +128,9 @@ async function startControlledProvider() { release: () => releaseResponse?.(), stop: async () => { releaseResponse?.(); - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); }, }; } @@ -141,7 +141,6 @@ async function connectOperator( ): Promise { return await new Promise((resolve, reject) => { let settled = false; - let timeout: ReturnType; const finish = (error?: Error) => { if (settled) { return; @@ -172,7 +171,7 @@ async function connectOperator( onConnectError: (error) => finish(error), onClose: (code, reason) => finish(new Error(`Gateway closed (${code}): ${reason}`)), }); - timeout = setTimeout( + const timeout = setTimeout( () => finish(new Error(`Gateway client connection timed out:\n${gateway.logs()}`)), REQUEST_TIMEOUT_MS, ); @@ -182,9 +181,8 @@ async function connectOperator( } function messageRole(message: unknown): string | undefined { - return message && typeof message === "object" - ? String((message as { role?: unknown }).role ?? "") - : undefined; + const role = message && typeof message === "object" ? (message as { role?: unknown }).role : null; + return typeof role === "string" ? role : undefined; } function messageText(message: unknown): string { diff --git a/test/e2e/qa-lab/runtime/agent-session-scope-continuity.e2e.test.ts b/test/e2e/qa-lab/runtime/agent-session-scope-continuity.e2e.test.ts index 72ed95fad058..21e89f87cd4e 100644 --- a/test/e2e/qa-lab/runtime/agent-session-scope-continuity.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/agent-session-scope-continuity.e2e.test.ts @@ -151,9 +151,9 @@ async function startDeterministicProvider() { baseUrl: `http://127.0.0.1:${address.port}`, requests, stop: async () => { - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); }, }; } @@ -161,7 +161,6 @@ async function startDeterministicProvider() { async function connectOperator(gateway: GatewayHandle): Promise { return await new Promise((resolve, reject) => { let settled = false; - let timeout: ReturnType; const finish = (error?: Error) => { if (settled) { return; @@ -192,7 +191,7 @@ async function connectOperator(gateway: GatewayHandle): Promise { onConnectError: (error) => finish(error), onClose: (code, reason) => finish(new Error(`Gateway closed (${code}): ${reason}`)), }); - timeout = setTimeout( + const timeout = setTimeout( () => finish(new Error(`Gateway client connection timed out:\n${gateway.logs()}`)), REQUEST_TIMEOUT_MS, ); diff --git a/test/e2e/qa-lab/runtime/agent-session-streaming.e2e.test.ts b/test/e2e/qa-lab/runtime/agent-session-streaming.e2e.test.ts index 8872bf32f158..b6ae8d766048 100644 --- a/test/e2e/qa-lab/runtime/agent-session-streaming.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/agent-session-streaming.e2e.test.ts @@ -157,9 +157,9 @@ async function startStreamingProvider() { transportRequests, stop: async () => { server.closeAllConnections(); - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); }, }; } @@ -170,7 +170,6 @@ async function connectOperator( ): Promise { return await new Promise((resolve, reject) => { let settled = false; - let timeout: ReturnType; const finish = (error?: Error) => { if (settled) { return; @@ -202,7 +201,7 @@ async function connectOperator( onConnectError: (error) => finish(error), onClose: (code, reason) => finish(new Error(`Gateway closed (${code}): ${reason}`)), }); - timeout = setTimeout( + const timeout = setTimeout( () => finish(new Error(`Gateway client connection timed out:\n${gateway.logs()}`)), REQUEST_TIMEOUT_MS, ); @@ -218,9 +217,8 @@ function asAgentEvent(event: GatewayEvent): AgentEvent | undefined { } function messageRole(message: unknown): string | undefined { - return message && typeof message === "object" - ? String((message as { role?: unknown }).role ?? "") - : undefined; + const role = message && typeof message === "object" ? (message as { role?: unknown }).role : null; + return typeof role === "string" ? role : undefined; } function messageText(message: unknown): string { diff --git a/test/e2e/qa-lab/runtime/agent-tool-safety-approvals.e2e.test.ts b/test/e2e/qa-lab/runtime/agent-tool-safety-approvals.e2e.test.ts index 36b69921bbb9..055a6fd4c782 100644 --- a/test/e2e/qa-lab/runtime/agent-tool-safety-approvals.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/agent-tool-safety-approvals.e2e.test.ts @@ -26,7 +26,9 @@ const SESSION_KEY = "agent:qa-agent:approval"; const ALLOWED_DECISIONS = ["allow-once", "deny"] as const; function flushDiagnostics(): Promise { - return new Promise((resolve) => setImmediate(resolve)); + return new Promise((resolve) => { + setImmediate(resolve); + }); } describe("agent tool safety approvals", () => { diff --git a/test/e2e/qa-lab/runtime/cached-health-snapshot-boundaries.ts b/test/e2e/qa-lab/runtime/cached-health-snapshot-boundaries.ts index ae5de23e25b5..101967a875bd 100644 --- a/test/e2e/qa-lab/runtime/cached-health-snapshot-boundaries.ts +++ b/test/e2e/qa-lab/runtime/cached-health-snapshot-boundaries.ts @@ -112,7 +112,9 @@ export async function runHandlerBoundaryProof() { refresh: sharedRefresh, eventLoop: { status: "live" }, }); - await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => { + setImmediate(resolve); + }); const stale = await invokeHealthHandler({ cached: snapshot({ ts: Date.now() - 60_001 }), @@ -159,23 +161,26 @@ export async function runHandlerBoundaryProof() { }); const firstResponse = first.responses[0]; + const publicRefreshCall = publicRefresh.refreshCalls[0]; return { cacheHitSameTimestamp: (firstResponse?.payload as { ts?: unknown } | undefined)?.ts === cached.ts, + // meta is wire-typed Record: assert the literal boolean so a + // truthy non-boolean (e.g. "true") cannot satisfy the cached contract. cachedMeta: firstResponse?.meta?.cached === true, passiveRefreshBounded: sharedRefreshCalls.length === 1 && second.responses[0]?.meta?.cached === true, staleRefresh: stale.refreshCalls.length === 1 && stale.responses[0]?.meta === undefined, explicitProbeRefresh: probe.refreshCalls.length === 1 && - probe.refreshCalls[0]?.probe === true && - probe.refreshCalls[0]?.includeSensitive === true, + (probe.refreshCalls[0]?.probe ?? false) && + (probe.refreshCalls[0]?.includeSensitive ?? false), lifecycleMismatchRefresh: lifecycle.refreshCalls.length === 1 && lifecycle.responses[0]?.meta === undefined, liveOverlayMerged: (firstResponse?.payload as { eventLoop?: { status?: unknown } } | undefined)?.eventLoop ?.status === "live", - publicSensitiveOmitted: publicRefresh.refreshCalls[0]?.includeSensitive === false, + publicSensitiveOmitted: publicRefreshCall !== undefined && !publicRefreshCall.includeSensitive, }; } @@ -277,11 +282,10 @@ async function runPluginToolProof(repoRoot: string) { }); const after = (await gateway.call("health", { probe: true })) as HealthSummary; return { - pluginLoaded: before.plugins?.loaded.includes(FIXTURE_PLUGIN_ID) === true, + pluginLoaded: before.plugins?.loaded.includes(FIXTURE_PLUGIN_ID) ?? false, pluginToolCataloged: containsString(catalog, FIXTURE_TOOL_NAME), pluginToolInvoked: containsString(invoked, FIXTURE_RESULT), - healthAfterTool: - after.ok === true && after.plugins?.loaded.includes(FIXTURE_PLUGIN_ID) === true, + healthAfterTool: after.ok && Boolean(after.plugins?.loaded.includes(FIXTURE_PLUGIN_ID)), }; } finally { await gateway?.stop().catch(() => undefined); @@ -328,7 +332,7 @@ export async function main(argv = process.argv.slice(2)) { try { const proof = await runCachedHealthSnapshotBoundariesProof(repoRoot); const failures = Object.entries(proof) - .filter(([, passed]) => passed !== true) + .filter(([, passed]) => !passed) .map(([name]) => `${name} failed`); writer.appendLog(`${JSON.stringify(proof, null, 2)}\n`); await writer.write({ diff --git a/test/e2e/qa-lab/runtime/channel-health-monitor-lifecycle-runtime.ts b/test/e2e/qa-lab/runtime/channel-health-monitor-lifecycle-runtime.ts index 2c430ca706bf..2f247603186c 100644 --- a/test/e2e/qa-lab/runtime/channel-health-monitor-lifecycle-runtime.ts +++ b/test/e2e/qa-lab/runtime/channel-health-monitor-lifecycle-runtime.ts @@ -34,7 +34,9 @@ type MonitorProof = { }; function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); } async function waitFor(predicate: () => boolean, label: string, timeoutMs = 1_500) { diff --git a/test/e2e/qa-lab/runtime/gateway-client-transport-defaults.e2e.test.ts b/test/e2e/qa-lab/runtime/gateway-client-transport-defaults.e2e.test.ts index 83f2749ce384..922f9a24a5b1 100644 --- a/test/e2e/qa-lab/runtime/gateway-client-transport-defaults.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/gateway-client-transport-defaults.e2e.test.ts @@ -262,7 +262,7 @@ describe("GatewayClient transport defaults", () => { firstSocket.resolve(socket); return; } - waitForImmediate().then(() => socket.close(1012, "retry")); + void waitForImmediate().then(() => socket.close(1012, "retry")); }); const client = new GatewayClient({ url, diff --git a/test/e2e/qa-lab/runtime/gateway-exec-approvals.e2e.test.ts b/test/e2e/qa-lab/runtime/gateway-exec-approvals.e2e.test.ts index fe5838f0e2dc..bcd230a8d693 100644 --- a/test/e2e/qa-lab/runtime/gateway-exec-approvals.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/gateway-exec-approvals.e2e.test.ts @@ -173,7 +173,9 @@ describe("gateway exec approvals QA", () => { .finally(() => { waitSettled = true; }); - await new Promise((resolve) => setTimeout(resolve, 25)); + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); expect(waitSettled).toBe(false); await expect( diff --git a/test/e2e/qa-lab/runtime/gateway-hosted-web.e2e.test.ts b/test/e2e/qa-lab/runtime/gateway-hosted-web.e2e.test.ts index ad8f38d36bab..eb586743f596 100644 --- a/test/e2e/qa-lab/runtime/gateway-hosted-web.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/gateway-hosted-web.e2e.test.ts @@ -59,7 +59,12 @@ function waitForWebSocketMessage(ws: WebSocket, expected: string): Promise 5_000, ); ws.on("message", (data) => { - if (data.toString() !== expected) { + const message = Array.isArray(data) + ? Buffer.concat(data).toString("utf8") + : Buffer.isBuffer(data) + ? data.toString("utf8") + : Buffer.from(data).toString("utf8"); + if (message !== expected) { return; } clearTimeout(timer); @@ -173,7 +178,7 @@ describe("Gateway hosted web surfaces", () => { const registerEntry = ( pluginId: string, - entry: typeof adminHttpRpcPlugin | typeof canvasPlugin, + entry: typeof adminHttpRpcPlugin, pluginConfig: Record = {}, ) => { entry.register( diff --git a/test/e2e/qa-lab/runtime/gateway-node-control-plane.e2e.test.ts b/test/e2e/qa-lab/runtime/gateway-node-control-plane.e2e.test.ts index 093c5b80d7a9..a154551c6f1a 100644 --- a/test/e2e/qa-lab/runtime/gateway-node-control-plane.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/gateway-node-control-plane.e2e.test.ts @@ -106,7 +106,7 @@ describe("Gateway node control plane", () => { if (event.event !== "node.invoke.request") { return; } - void respondToInvocation(node, event.payload, invocations).catch((error) => { + void respondToInvocation(node, event.payload, invocations).catch((error: unknown) => { handlerErrors.push(error instanceof Error ? error : new Error(String(error))); }); }, @@ -334,7 +334,6 @@ async function connectClient(params: { }): Promise { return await new Promise((resolve, reject) => { let settled = false; - let timeout: ReturnType; const finish = (error?: Error) => { if (settled) { return; @@ -370,7 +369,7 @@ async function connectClient(params: { onConnectError: (error) => finish(error), onClose: (code, reason) => finish(new Error(`Gateway closed (${code}): ${reason}`)), }); - timeout = setTimeout( + const timeout = setTimeout( () => finish(new Error(`Gateway client connection timed out:\n${params.gateway.logs()}`)), REQUEST_TIMEOUT_MS, ); diff --git a/test/e2e/qa-lab/runtime/gateway-node-exec-approvals.e2e.test.ts b/test/e2e/qa-lab/runtime/gateway-node-exec-approvals.e2e.test.ts index 648c4480e8f9..65520dd06ae0 100644 --- a/test/e2e/qa-lab/runtime/gateway-node-exec-approvals.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/gateway-node-exec-approvals.e2e.test.ts @@ -85,13 +85,13 @@ describe("Gateway node exec approvals", () => { gateway, identity, operator, - onEvent: firstInbox.onEvent, + onEvent: (event) => firstInbox.onEvent(event), }); replacementNode = await connectNode({ gateway, identity, - onEvent: replacementInbox.onEvent, + onEvent: (event) => replacementInbox.onEvent(event), }); await waitForConnectedNode(operator, identity.deviceId, gateway.logs); @@ -251,7 +251,6 @@ async function connectClient(params: { }): Promise { return await new Promise((resolve, reject) => { let settled = false; - let timeout: ReturnType; const finish = (client: GatewayClient, error?: Error) => { if (settled) { return; @@ -286,7 +285,7 @@ async function connectClient(params: { onConnectError: (error) => finish(client, error), onClose: (code, reason) => finish(client, new Error(`Gateway closed (${code}): ${reason}`)), }); - timeout = setTimeout( + const timeout = setTimeout( () => finish(client, new Error(`Gateway client connection timed out:\n${params.gateway.logs()}`)), REQUEST_TIMEOUT_MS, diff --git a/test/e2e/qa-lab/runtime/gateway-node-pending-work.e2e.test.ts b/test/e2e/qa-lab/runtime/gateway-node-pending-work.e2e.test.ts index 1715425ef6bb..524e95f1f3ec 100644 --- a/test/e2e/qa-lab/runtime/gateway-node-pending-work.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/gateway-node-pending-work.e2e.test.ts @@ -99,7 +99,7 @@ describe("Gateway node pending work", () => { return; } void rejectBackgroundInvocation(node, event.payload, backgroundRequests).catch( - (error) => { + (error: unknown) => { handlerErrors.push(error instanceof Error ? error : new Error(String(error))); }, ); @@ -328,7 +328,6 @@ async function connectClient(params: { }): Promise { return await new Promise((resolve, reject) => { let settled = false; - let timeout: ReturnType; const finish = (error?: Error) => { if (settled) { return; @@ -364,7 +363,7 @@ async function connectClient(params: { onConnectError: (error) => finish(error), onClose: (code, reason) => finish(new Error(`Gateway closed (${code}): ${reason}`)), }); - timeout = setTimeout( + const timeout = setTimeout( () => finish(new Error(`Gateway client connection timed out:\n${params.gateway.logs()}`)), REQUEST_TIMEOUT_MS, ); diff --git a/test/e2e/qa-lab/runtime/gateway-protocol-artifacts.ts b/test/e2e/qa-lab/runtime/gateway-protocol-artifacts.ts index 4bab72e93a9b..7d10dafebe2c 100644 --- a/test/e2e/qa-lab/runtime/gateway-protocol-artifacts.ts +++ b/test/e2e/qa-lab/runtime/gateway-protocol-artifacts.ts @@ -224,31 +224,36 @@ export function buildCanonicalProtocolSchema( schemas: Record = ProtocolSchemas, methodMetadata = listCoreGatewayMethodMetadata(), ): ProtocolSchemaDocument { - return JSON.parse( - JSON.stringify({ - $schema: "http://json-schema.org/draft-07/schema#", - $id: "https://openclaw.ai/protocol.schema.json", - title: "OpenClaw Gateway Protocol", - description: "Handshake, request/response, and event frames for the Gateway WebSocket.", - oneOf: [ - { $ref: "#/definitions/RequestFrame" }, - { $ref: "#/definitions/ResponseFrame" }, - { $ref: "#/definitions/EventFrame" }, - ], - discriminator: { - propertyName: "type", - mapping: { - req: "#/definitions/RequestFrame", - res: "#/definitions/ResponseFrame", - event: "#/definitions/EventFrame", - }, + return structuredClone({ + $schema: "http://json-schema.org/draft-07/schema#", + $id: "https://openclaw.ai/protocol.schema.json", + title: "OpenClaw Gateway Protocol", + description: "Handshake, request/response, and event frames for the Gateway WebSocket.", + oneOf: [ + { $ref: "#/definitions/RequestFrame" }, + { $ref: "#/definitions/ResponseFrame" }, + { $ref: "#/definitions/EventFrame" }, + ], + discriminator: { + propertyName: "type", + mapping: { + req: "#/definitions/RequestFrame", + res: "#/definitions/ResponseFrame", + event: "#/definitions/EventFrame", }, - methods: Object.fromEntries( - methodMetadata.map(({ name, scope, since }) => [name, { since, scope }]), - ), - definitions: schemas, - }), - ) as ProtocolSchemaDocument; + }, + methods: Object.fromEntries( + // Omit undefined `since` so structuredClone matches the prior JSON + // round-trip byte shape and the document satisfies the schema type. + methodMetadata.map(({ name, scope, since }) => [ + name, + { ...(since === undefined ? {} : { since }), scope }, + ]), + ), + definitions: schemas, + // The runtime consumers validate this JSON document; the literal cannot + // structurally satisfy ProtocolSchemaDocument's stricter schema shapes. + }) as unknown as ProtocolSchemaDocument; } export function assertPublishedProtocolSchema(params: { @@ -257,7 +262,7 @@ export function assertPublishedProtocolSchema(params: { published: ProtocolSchemaDocument; }) { assert.deepEqual( - JSON.parse(JSON.stringify(params.builtSchemas)), + structuredClone(params.builtSchemas), params.canonical.definitions, "built package schema registry differs from the canonical TypeBox registry", ); @@ -534,17 +539,17 @@ async function runGatewayProtocolArtifactsProducer( try { await runCommand({ args: ["protocol:check"], - appendLog: writer.appendLog, + appendLog: (line) => writer.appendLog(line), command: "pnpm", cwd: options.repoRoot, }); const summary = await packAndInspectProtocol({ - appendLog: writer.appendLog, + appendLog: (line) => writer.appendLog(line), artifactBase: options.artifactBase, repoRoot: options.repoRoot, }); await compileAndRunSwiftProtocolModels({ - appendLog: writer.appendLog, + appendLog: (line) => writer.appendLog(line), artifactBase: options.artifactBase, repoRoot: options.repoRoot, }); diff --git a/test/e2e/qa-lab/runtime/gateway-rpc-account-health.ts b/test/e2e/qa-lab/runtime/gateway-rpc-account-health.ts index a43ee417ceca..9c31b83754e6 100644 --- a/test/e2e/qa-lab/runtime/gateway-rpc-account-health.ts +++ b/test/e2e/qa-lab/runtime/gateway-rpc-account-health.ts @@ -32,7 +32,9 @@ type GatewayAccountHealthProof = { }; function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); } export function withSiblingAccount(config: OpenClawConfig, baseUrl?: string): OpenClawConfig { @@ -43,18 +45,16 @@ export function withSiblingAccount(config: OpenClawConfig, baseUrl?: string): Op ...config.channels, [CHANNEL_ID]: { ...channel, - ...(baseUrl - ? { - enabled: true, - baseUrl, - botUserId: "openclaw", - botDisplayName: "OpenClaw QA", - allowFrom: ["*"], - pollTimeoutMs: 250, - } - : {}), + ...(baseUrl && { + enabled: true, + baseUrl, + botUserId: "openclaw", + botDisplayName: "OpenClaw QA", + allowFrom: ["*"], + pollTimeoutMs: 250, + }), accounts: { - ...((channel?.accounts as Record | undefined) ?? {}), + ...(channel?.accounts as Record | undefined), [TARGET_ACCOUNT_ID]: { enabled: true }, }, }, diff --git a/test/e2e/qa-lab/runtime/gateway-rpc-automation.e2e.test.ts b/test/e2e/qa-lab/runtime/gateway-rpc-automation.e2e.test.ts index a9750d53cfea..2f8fadda93cc 100644 --- a/test/e2e/qa-lab/runtime/gateway-rpc-automation.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/gateway-rpc-automation.e2e.test.ts @@ -211,10 +211,9 @@ describe("Gateway task and automation RPCs", () => { providers: { [provider.providerId]: { ...provider.config, - models: provider.config.models.map((model) => ({ - ...model, - input: Array.from(model.input), - })), + models: provider.config.models.map((model) => + Object.assign({}, model, { input: Array.from(model.input) }), + ), }, }, }, @@ -428,7 +427,9 @@ describe("Gateway task and automation RPCs", () => { } releaseTaskResponse?.(); providerServer.closeAllConnections(); - await new Promise((resolve) => providerServer.close(() => resolve())); + await new Promise((resolve) => { + providerServer.close(() => resolve()); + }); envSnapshot.restore(); } }, diff --git a/test/e2e/qa-lab/runtime/gateway-ssh-tunnels.ts b/test/e2e/qa-lab/runtime/gateway-ssh-tunnels.ts index 2de25f208f26..bcfce2ed4665 100644 --- a/test/e2e/qa-lab/runtime/gateway-ssh-tunnels.ts +++ b/test/e2e/qa-lab/runtime/gateway-ssh-tunnels.ts @@ -431,7 +431,9 @@ exec "$@" signal: NodeJS.Signals | null; }>((resolve, reject) => { child.once("error", reject); - child.once("close", (code, signal) => resolve({ code, signal })); + child.once("close", (exitCode, exitSignal) => + resolve({ code: exitCode, signal: exitSignal }), + ); }); const evidencePath = path.join(options.artifactBase, QA_EVIDENCE_FILENAME); const evidence = await fs diff --git a/test/e2e/qa-lab/runtime/gateway-stability-runtime.ts b/test/e2e/qa-lab/runtime/gateway-stability-runtime.ts index 749fdfbc97d3..d68b712b1358 100644 --- a/test/e2e/qa-lab/runtime/gateway-stability-runtime.ts +++ b/test/e2e/qa-lab/runtime/gateway-stability-runtime.ts @@ -94,6 +94,7 @@ function parseOptions(argv: string[], repoRoot = process.cwd()): GatewayStabilit function parseCliJson( label: string, result: Awaited>, + parse: (value: unknown) => T = (value) => value as T, ): T { if (result.code !== 0) { throw new Error( @@ -101,7 +102,7 @@ function parseCliJson( ); } try { - return JSON.parse(result.stdout) as T; + return parse(JSON.parse(result.stdout) as unknown); } catch (error) { throw new Error( `${label} returned invalid JSON: ${formatErrorMessage(error)}\n${result.stdout}`, diff --git a/test/e2e/qa-lab/runtime/gateway-support-export-runtime.ts b/test/e2e/qa-lab/runtime/gateway-support-export-runtime.ts index 1ed5d98e1377..2255792e5734 100644 --- a/test/e2e/qa-lab/runtime/gateway-support-export-runtime.ts +++ b/test/e2e/qa-lab/runtime/gateway-support-export-runtime.ts @@ -67,6 +67,7 @@ function parseOptions( function parseCliJson( label: string, result: Awaited>, + parse: (value: unknown) => T = (value) => value as T, ): T { if (result.code !== 0) { throw new Error( @@ -74,7 +75,7 @@ function parseCliJson( ); } try { - return JSON.parse(result.stdout) as T; + return parse(JSON.parse(result.stdout) as unknown); } catch (error) { throw new Error( `${label} returned invalid JSON: ${formatErrorMessage(error)}\n${result.stdout}`, diff --git a/test/e2e/qa-lab/runtime/gateway-tls-pinning.ts b/test/e2e/qa-lab/runtime/gateway-tls-pinning.ts index 2fe5247b9b5b..c911f5d27e28 100644 --- a/test/e2e/qa-lab/runtime/gateway-tls-pinning.ts +++ b/test/e2e/qa-lab/runtime/gateway-tls-pinning.ts @@ -206,9 +206,9 @@ function withTimeout(promise: Promise, label: string): Promise { clearTimeout(timer); resolve(value); }, - (error) => { + (error: unknown) => { clearTimeout(timer); - reject(error); + reject(error instanceof Error ? error : new Error(String(error))); }, ); }); @@ -235,7 +235,7 @@ async function connectWithExactPin(url: string, tlsFingerprint: string): Promise try { client.start(); await withTimeout(hello.promise, "Gateway exact-pin hello"); - const health = await client.request>("health", {}); + const health = await client.request("health", {}); return health !== null && typeof health === "object"; } finally { await client.stopAndWait().catch(() => undefined); diff --git a/test/e2e/qa-lab/runtime/managed-worktrees-cli-product-proof.e2e.test.ts b/test/e2e/qa-lab/runtime/managed-worktrees-cli-product-proof.e2e.test.ts index c9f9d9317e46..1b75aa6392d9 100644 --- a/test/e2e/qa-lab/runtime/managed-worktrees-cli-product-proof.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/managed-worktrees-cli-product-proof.e2e.test.ts @@ -35,13 +35,17 @@ afterEach(async () => { } }); -function parseCommandJson(label: string, result: CommandResult): T { +function parseCommandJson( + label: string, + result: CommandResult, + parse: (value: unknown) => T = (value) => value as T, +): T { if (result.code !== 0) { throw new Error( `${label} failed with exit ${String(result.code)}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, ); } - return JSON.parse(result.stdout) as T; + return parse(JSON.parse(result.stdout) as unknown); } async function git(cwd: string, ...args: string[]): Promise { diff --git a/test/e2e/qa-lab/runtime/managed-worktrees-workboard-lifecycle-product-proof.e2e.test.ts b/test/e2e/qa-lab/runtime/managed-worktrees-workboard-lifecycle-product-proof.e2e.test.ts index f31521cc3d28..6c2508b2700b 100644 --- a/test/e2e/qa-lab/runtime/managed-worktrees-workboard-lifecycle-product-proof.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/managed-worktrees-workboard-lifecycle-product-proof.e2e.test.ts @@ -22,9 +22,11 @@ type WorkboardWorkspace = { type WorkboardCard = { id: string; runId?: string; + status?: string; metadata?: { automation?: { workspace?: WorkboardWorkspace } }; }; type WorkboardCreateResult = { card: WorkboardCard }; +type WorkboardCompleteResult = { card: WorkboardCard }; type WorkboardListResult = { cards: WorkboardCard[] }; type WorkboardDispatchResult = { started: Array<{ cardId: string; runId: string; sessionKey: string; title: string }>; @@ -149,6 +151,33 @@ async function waitForMaterializedWorktree(params: { throw new Error(`timed out waiting for managed worktree ${params.name}`); } +async function dispatchCardAndWaitForWorktree(params: { + boardId: string; + cardId: string; + name: string; + stateDir: string; +}): Promise<{ materializedPath: string; started: WorkboardDispatchResult["started"][number] }> { + if (!harness) { + throw new Error("QA gateway harness is not running"); + } + const dispatchPromise = harness.gateway.call("workboard.cards.dispatch", { + boardId: params.boardId, + }); + const materializedPromise = waitForMaterializedWorktree({ + name: params.name, + stateDir: params.stateDir, + }); + const dispatch = (await dispatchPromise) as WorkboardDispatchResult; + expect(dispatch.startFailures).toEqual([]); + expect(dispatch.started).toEqual([ + expect.objectContaining({ cardId: params.cardId, runId: expect.any(String) }), + ]); + return { + materializedPath: await materializedPromise, + started: dispatch.started[0]!, + }; +} + async function waitForWorktreeState(params: { id: string; predicate: (record: ManagedWorktreeRecord | undefined) => boolean; @@ -168,7 +197,7 @@ async function waitForWorktreeState(params: { describe("managed worktrees Workboard-owner product proof", () => { it( - "materializes, writes back, runs, and removes a clean card worktree losslessly", + "removes clean card worktrees and records dirty run-end retention", { timeout: 240_000 }, async () => { const canonicalTmp = await fs.realpath(os.tmpdir()); @@ -180,14 +209,12 @@ describe("managed worktrees Workboard-owner product proof", () => { const card = await createCard({ boardId, repo, title: "Clean worktree lifecycle" }); const name = managedWorktreeName(card.id); - const dispatchPromise = activeHarness.gateway.call("workboard.cards.dispatch", { boardId }); - const materializedPath = await waitForMaterializedWorktree({ name, stateDir }); - const dispatch = (await dispatchPromise) as WorkboardDispatchResult; - expect(dispatch.startFailures).toEqual([]); - expect(dispatch.started).toEqual([ - expect.objectContaining({ cardId: card.id, runId: expect.any(String) }), - ]); - const started = dispatch.started[0]!; + const { materializedPath, started } = await dispatchCardAndWaitForWorktree({ + boardId, + cardId: card.id, + name, + stateDir, + }); const cards = (await activeHarness.gateway.call("workboard.cards.list", { boardId, @@ -224,15 +251,76 @@ describe("managed worktrees Workboard-owner product proof", () => { const removed = await waitForWorktreeState({ id: activeRecord?.id ?? "", - predicate: (record) => record?.removedAt !== undefined, + predicate: (record) => + record?.removedAt !== undefined && record.runEndCleanup?.outcome === "removed-lossless", timeoutMs: 30_000, }); expect(removed).toMatchObject({ id: activeRecord?.id, snapshotRef: `refs/openclaw/snapshots/${activeRecord?.id}`, removedAt: expect.any(Number), + runEndCleanup: { + outcome: "removed-lossless", + at: expect.any(Number), + }, }); await expect(fs.access(materializedPath)).rejects.toMatchObject({ code: "ENOENT" }); + + // The mock provider reaches terminal without calling the Workboard worker protocol. + // Close the first card at the operator boundary so its agent-global owner slot is free. + const completed = (await activeHarness.gateway.call("workboard.cards.complete", { + id: card.id, + summary: "Clean worktree lifecycle completed.", + })) as WorkboardCompleteResult; + expect(completed.card).toMatchObject({ id: card.id, status: "done" }); + + const dirtyBoardId = "qa-worktree-dirty"; + const dirtyCard = await createCard({ + boardId: dirtyBoardId, + repo, + title: "Dirty worktree retention", + }); + const dirtyName = managedWorktreeName(dirtyCard.id); + const { materializedPath: dirtyPath, started: dirtyStarted } = + await dispatchCardAndWaitForWorktree({ + boardId: dirtyBoardId, + cardId: dirtyCard.id, + name: dirtyName, + stateDir, + }); + const dirtyFile = path.join(dirtyPath, "untracked-note.txt"); + await fs.writeFile(dirtyFile, "retain this worktree\n"); + const dirtyRecord = (await listWorktrees()).worktrees.find( + (record) => record.ownerKind === "workboard" && record.ownerId === dirtyCard.id, + ); + expect(dirtyRecord).toMatchObject({ + name: dirtyName, + path: dirtyPath, + ownerKind: "workboard", + ownerId: dirtyCard.id, + }); + + const dirtyTerminal = (await activeHarness.gateway.call( + "agent.wait", + { runId: dirtyStarted.runId, timeoutMs: 30_000 }, + { timeoutMs: 35_000 }, + )) as GatewayRunResult; + expect(dirtyTerminal.status).toBe("ok"); + + const retained = await waitForWorktreeState({ + id: dirtyRecord?.id ?? "", + predicate: (record) => record?.runEndCleanup?.outcome === "retained-dirty", + timeoutMs: 30_000, + }); + expect(retained).toMatchObject({ + id: dirtyRecord?.id, + runEndCleanup: { + outcome: "retained-dirty", + at: expect.any(Number), + }, + }); + expect(retained?.removedAt).toBeUndefined(); + await expect(fs.readFile(dirtyFile, "utf8")).resolves.toBe("retain this worktree\n"); }, ); }); diff --git a/test/e2e/qa-lab/runtime/openclaw-exec-process-lifecycle.e2e.test.ts b/test/e2e/qa-lab/runtime/openclaw-exec-process-lifecycle.e2e.test.ts index e76d42c03303..4ac5a09f824c 100644 --- a/test/e2e/qa-lab/runtime/openclaw-exec-process-lifecycle.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/openclaw-exec-process-lifecycle.e2e.test.ts @@ -105,8 +105,8 @@ test("OpenClaw executes and controls the complete real process lifecycle", async const shellMarker = `shell-route-${process.pid}`; const foregroundCommand = process.platform === "win32" - ? `Write-Output -NoNewline ${shellQuote(shellMarker)}; Write-Output -NoNewline \"|$env:OPENCLAW_SHELL\"` - : `printf '%s' ${shellQuote(shellMarker)} && printf '|%s' \"$OPENCLAW_SHELL\"`; + ? `Write-Output -NoNewline ${shellQuote(shellMarker)}; Write-Output -NoNewline "|$env:OPENCLAW_SHELL"` + : `printf '%s' ${shellQuote(shellMarker)} && printf '|%s' "$OPENCLAW_SHELL"`; const foreground = await foregroundExecTool.execute("foreground-shell", { command: foregroundCommand, }); diff --git a/test/e2e/qa-lab/runtime/otel-gateway-runtime.e2e.test.ts b/test/e2e/qa-lab/runtime/otel-gateway-runtime.e2e.test.ts index f3dcdbcd93ba..2905af24c818 100644 --- a/test/e2e/qa-lab/runtime/otel-gateway-runtime.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/otel-gateway-runtime.e2e.test.ts @@ -190,7 +190,7 @@ describe("diagnostics-otel gateway runtime", () => { }>; const readPlans = scenarioRequests.filter((request) => request.plannedToolName === "read"); const finalizations = scenarioRequests.filter((request) => - String(request.allInputText ?? "").includes( + (request.allInputText ?? "").includes( "The previous assistant turn completed its tool calls but did not produce a user-visible answer.", ), ); @@ -206,13 +206,13 @@ describe("diagnostics-otel gateway runtime", () => { (item) => item.type === "function_call" && item.name === "exec" && - String(item.arguments ?? "").includes("qa-failed-terminal-missing-file.txt"), + JSON.stringify(item.arguments ?? "").includes("qa-failed-terminal-missing-file.txt"), ); const failedExecOutputs = finalizationInput.filter( (item) => item.type === "function_call_output" && item.call_id === failedExecCalls[0]?.call_id && - /ENOENT|no such file/iu.test(String(item.output ?? "")), + /ENOENT|no such file/iu.test(JSON.stringify(item.output ?? "")), ); expect(failedExecCalls).toHaveLength(1); expect(failedExecOutputs).toHaveLength(1); diff --git a/test/e2e/qa-lab/runtime/otel-test-support.ts b/test/e2e/qa-lab/runtime/otel-test-support.ts index c9462e6b698c..04ae2ef8a8c0 100644 --- a/test/e2e/qa-lab/runtime/otel-test-support.ts +++ b/test/e2e/qa-lab/runtime/otel-test-support.ts @@ -99,7 +99,7 @@ export function runModelCallAndCaptureTraceparent(params: { suppressPluginHooks: true, }, ); - wrapped({} as never, {} as never); + void wrapped({} as never, {} as never); return outboundTraceparent; } diff --git a/test/e2e/qa-lab/runtime/session-transcript-cli-product-proof.e2e.test.ts b/test/e2e/qa-lab/runtime/session-transcript-cli-product-proof.e2e.test.ts index 0bc07ac49f6b..82fac72bbc77 100644 --- a/test/e2e/qa-lab/runtime/session-transcript-cli-product-proof.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/session-transcript-cli-product-proof.e2e.test.ts @@ -66,13 +66,17 @@ afterEach(async () => { instance = undefined; }); -function parseCommandJson(label: string, result: CommandResult): T { +function parseCommandJson( + label: string, + result: CommandResult, + parse: (value: unknown) => T = (value) => value as T, +): T { if (result.code !== 0) { throw new Error( `${label} failed with exit ${String(result.code)}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, ); } - return JSON.parse(result.stdout) as T; + return parse(JSON.parse(result.stdout) as unknown); } async function seedTranscript(stateDir: string, env: NodeJS.ProcessEnv) { diff --git a/test/e2e/qa-lab/tui/tui-pty-evidence-producer.ts b/test/e2e/qa-lab/tui/tui-pty-evidence-producer.ts index 973315831844..725ae4f5fe3e 100644 --- a/test/e2e/qa-lab/tui/tui-pty-evidence-producer.ts +++ b/test/e2e/qa-lab/tui/tui-pty-evidence-producer.ts @@ -176,10 +176,11 @@ function readTuiPtyCase(value: unknown, index: number): TuiPtyCase { ); } try { - new RegExp(testNamePattern); + RegExp(testNamePattern); } catch (error) { throw new Error( `execution.config.tuiPtyCases[${index}].testNamePattern is invalid: ${formatErrorMessage(error)}`, + { cause: error }, ); } return { coverageId, testFile, testNamePattern }; diff --git a/test/external-script-modules.d.ts b/test/external-script-modules.d.ts index a73b4881283b..bf55338f2be7 100644 --- a/test/external-script-modules.d.ts +++ b/test/external-script-modules.d.ts @@ -315,7 +315,7 @@ declare module "*openclaw-live-updater/scripts/update-main.mjs" { args: string[], checkout: string, options?: Record, - ) => unknown | Promise, + ) => unknown, checkout: string, expectedSha: string, sleep?: (ms: number) => void | Promise, diff --git a/test/helpers/openclaw-test-instance.test.ts b/test/helpers/openclaw-test-instance.test.ts index 02526abfd011..b1bd9b05f628 100644 --- a/test/helpers/openclaw-test-instance.test.ts +++ b/test/helpers/openclaw-test-instance.test.ts @@ -242,9 +242,10 @@ describe("openclaw test instance", () => { it("force-kills Windows gateway descendants before retry cleanup settles", async () => { const stdout = new PassThrough(); const stderr = new PassThrough(); + const kill = vi.fn(() => true); const child = { exitCode: 1, - kill: vi.fn(() => true), + kill, pid: 12345, signalCode: null, stderr, @@ -274,7 +275,7 @@ describe("openclaw test instance", () => { timeout: 10_000, }, ); - expect(child.kill).not.toHaveBeenCalled(); + expect(kill).not.toHaveBeenCalled(); expect(stdout.closed).toBe(true); expect(stderr.closed).toBe(true); }); @@ -333,7 +334,14 @@ describe("openclaw test instance", () => { it("keeps stalled readiness probes inside the startup deadline", async () => { const fetchImpl = vi.fn((_url, init) => { return new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + init?.signal?.addEventListener( + "abort", + () => { + const reason = init.signal?.reason; + reject(reason instanceof Error ? reason : new Error(String(reason))); + }, + { once: true }, + ); }); }); const startedAt = Date.now(); @@ -350,7 +358,14 @@ describe("openclaw test instance", () => { const processState = createGatewayProcessState(); const fetchImpl = vi.fn((_url, init) => { return new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + init?.signal?.addEventListener( + "abort", + () => { + const reason = init.signal?.reason; + reject(reason instanceof Error ? reason : new Error(String(reason))); + }, + { once: true }, + ); }); }); const startedAt = Date.now(); diff --git a/test/helpers/openclaw-test-instance.ts b/test/helpers/openclaw-test-instance.ts index 7d85f3314b62..24ac6097f54e 100644 --- a/test/helpers/openclaw-test-instance.ts +++ b/test/helpers/openclaw-test-instance.ts @@ -501,9 +501,9 @@ export async function createOpenClawTestInstance( const releaseGatewayChild = async ( target: OpenClawTestProcess, deadline: number, - options: GatewayProcessStopOptions = {}, + stopOptions: GatewayProcessStopOptions = {}, ): Promise => { - const closed = await stopGatewayProcess(target, deadline, stopTimeoutMs, options); + const closed = await stopGatewayProcess(target, deadline, stopTimeoutMs, stopOptions); if (closed && child === target) { child = undefined; } diff --git a/test/helpers/sqlite-sessions-transcripts-flip-proof-assertions.ts b/test/helpers/sqlite-sessions-transcripts-flip-proof-assertions.ts index 9a6cea123eeb..519858260b86 100644 --- a/test/helpers/sqlite-sessions-transcripts-flip-proof-assertions.ts +++ b/test/helpers/sqlite-sessions-transcripts-flip-proof-assertions.ts @@ -31,7 +31,7 @@ export function assertSqliteFlipProofCore(report: SqliteFlipProofReport): void { checkpoint.label === "after-startup-import" && checkpoint.gatewayLogTail?.includes( "session: imported legacy session metadata/transcripts into SQLite", - ) === true && + ) && report.oldStateSessionKeys.every((key) => checkpoint.sqlite.trackedEntries.some((entry) => entry.sessionKey === key), ) && diff --git a/test/helpers/sqlite-sessions-transcripts-flip-proof.ts b/test/helpers/sqlite-sessions-transcripts-flip-proof.ts index 4ae7bd7e7d41..c48a09dcb8da 100644 --- a/test/helpers/sqlite-sessions-transcripts-flip-proof.ts +++ b/test/helpers/sqlite-sessions-transcripts-flip-proof.ts @@ -2108,8 +2108,13 @@ function readTrackedEntries(db: DatabaseSync, trackedSessionKeys: readonly strin .map((row) => { const sessionId = typeof row.sessionId === "string" ? row.sessionId : ""; const entry = typeof row.entryJson === "string" ? parseEntryJson(row.entryJson) : undefined; - return { - ...(entry ? { entry } : {}), + const trackedEntry: { + entry?: Record; + sessionId: string; + sessionKey: string; + trajectoryEvents: number; + transcriptEvents: number; + } = { sessionId, sessionKey: typeof row.sessionKey === "string" ? row.sessionKey : "", trajectoryEvents: scalarNumber( @@ -2123,6 +2128,10 @@ function readTrackedEntries(db: DatabaseSync, trackedSessionKeys: readonly strin [sessionId], ), }; + if (entry) { + trackedEntry.entry = entry; + } + return trackedEntry; }); } diff --git a/test/scripts/bench-agent-concurrency.test.ts b/test/scripts/bench-agent-concurrency.test.ts index a10530e00a15..85014fce4164 100644 --- a/test/scripts/bench-agent-concurrency.test.ts +++ b/test/scripts/bench-agent-concurrency.test.ts @@ -124,7 +124,9 @@ describe("agent concurrency benchmark", () => { await expect( Promise.race([ drain.then(() => "drained"), - new Promise((resolve) => setImmediate(() => resolve("pending"))), + new Promise((resolve) => { + setImmediate(() => resolve("pending")); + }), ]), ).resolves.toBe("pending"); diff --git a/test/scripts/bench-gateway-concurrency.test.ts b/test/scripts/bench-gateway-concurrency.test.ts index 1592df140d45..2ae00f6dc633 100644 --- a/test/scripts/bench-gateway-concurrency.test.ts +++ b/test/scripts/bench-gateway-concurrency.test.ts @@ -151,7 +151,7 @@ describe("gateway concurrency benchmark script", () => { { readOutput: () => "mock output" }, ); expect(failure).toMatch( - /readyz: ok=false status=503 latencyMs=\d+\.\d error=none\n sessionsList: ok=false status=n\/a latencyMs=\d+\.\d error="sessions\.list failed: unauthorized"\n controlUi: ok=false status=200 latencyMs=\d+\.\d error="response body did not contain { ensurePlaywrightChromium({ env: { PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH: " /snap/bin/chromium " }, executablePath: "/cache/chromium/chrome", - existsSync: (path: string) => path === "/snap/bin/chromium", + existsSync: (candidatePath: string) => candidatePath === "/snap/bin/chromium", spawnSync, }), ).toBe(0); @@ -68,7 +68,7 @@ describe("ensurePlaywrightChromium", () => { expect( ensurePlaywrightChromium({ executablePath: "/cache/chromium/chrome", - existsSync: (path: string) => path === "/usr/bin/chromium-browser", + existsSync: (candidatePath: string) => candidatePath === "/usr/bin/chromium-browser", log: (line: string) => logs.push(line), spawnSync, }), @@ -100,9 +100,9 @@ describe("ensurePlaywrightChromium", () => { cwd: "/repo", env: { PATH: "/bin" }, executablePath: "/cache/chromium/chrome", - existsSync: (path: string) => - path === "/usr/bin/chromium-browser" || - (managedChromiumInstalled && path === "/cache/chromium/chrome"), + existsSync: (candidatePath: string) => + candidatePath === "/usr/bin/chromium-browser" || + (managedChromiumInstalled && candidatePath === "/cache/chromium/chrome"), requirePlaywrightChromium: true, spawnSync, stdio: "pipe", @@ -213,7 +213,7 @@ describe("ensurePlaywrightChromium", () => { ensureFfmpeg: true, env: { PATH: "/bin" }, executablePath: "/cache/chromium/chrome", - existsSync: (path: string) => path === "/usr/bin/chromium-browser", + existsSync: (candidatePath: string) => candidatePath === "/usr/bin/chromium-browser", log: (line: string) => logs.push(line), spawnSync, stdio: "pipe", @@ -238,15 +238,15 @@ describe("ensurePlaywrightChromium", () => { it("skips a broken system Chromium binary and uses the first runnable candidate", () => { const logs: string[] = []; - const spawnSync = vi.fn((path: string) => ({ - status: path === "/usr/bin/google-chrome" ? 0 : 127, + const spawnSync = vi.fn((candidatePath: string) => ({ + status: candidatePath === "/usr/bin/google-chrome" ? 0 : 127, })); expect( ensurePlaywrightChromium({ executablePath: "/cache/chromium/chrome", - existsSync: (path: string) => - path === "/snap/bin/chromium" || path === "/usr/bin/google-chrome", + existsSync: (candidatePath: string) => + candidatePath === "/snap/bin/chromium" || candidatePath === "/usr/bin/google-chrome", log: (line: string) => logs.push(line), spawnSync, }), @@ -429,8 +429,8 @@ describe("ensurePlaywrightChromium", () => { cwd: "/repo", env: { CI: "1", PATH: "/bin" }, executablePath: "/cache/chromium/chrome", - existsSync: (path: string) => - installedSystemChromium && path === "/usr/bin/chromium-browser", + existsSync: (candidatePath: string) => + installedSystemChromium && candidatePath === "/usr/bin/chromium-browser", getuid: () => 0, log: (line: string) => logs.push(line), platform: "linux", diff --git a/test/scripts/managed-child-process.test.ts b/test/scripts/managed-child-process.test.ts index 46bd3b59151a..d54bcaf3bd11 100644 --- a/test/scripts/managed-child-process.test.ts +++ b/test/scripts/managed-child-process.test.ts @@ -386,7 +386,7 @@ setInterval(() => {}, 1_000); }); posixIt("waits through transient indeterminate process-group state", async () => { - const originalKill = process.kill; + const originalKill = process.kill.bind(process); let childPid = 0; let injectedIndeterminate = false; process.kill = ((pid: number, signal?: NodeJS.Signals | number) => { @@ -419,7 +419,7 @@ setInterval(() => {}, 1_000); }); posixIt("accepts a process group that vanishes before its cleanup signal", async () => { - const originalKill = process.kill; + const originalKill = process.kill.bind(process); let childPid = 0; let injectedLiveGroup = false; process.kill = ((pid: number, signal?: NodeJS.Signals | number) => { diff --git a/test/scripts/npm-placeholder-publication.test.ts b/test/scripts/npm-placeholder-publication.test.ts index fe156afe1171..2062cf38786a 100644 --- a/test/scripts/npm-placeholder-publication.test.ts +++ b/test/scripts/npm-placeholder-publication.test.ts @@ -261,7 +261,12 @@ describe("npm placeholder publication", () => { targetSha: SHA, workflowSha: WORKFLOW_SHA, fetchImpl: async (input) => - String(input).includes(encodeURIComponent(names[0])) + (typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url + ).includes(encodeURIComponent(names[0])) ? registryResponse() : existingMeta.clone(), }); diff --git a/test/scripts/openclaw-live-updater.test.ts b/test/scripts/openclaw-live-updater.test.ts index 8a1c88b1fccc..6cc23f70bae3 100644 --- a/test/scripts/openclaw-live-updater.test.ts +++ b/test/scripts/openclaw-live-updater.test.ts @@ -1832,7 +1832,9 @@ console.log(JSON.stringify({ ok: true, channels: {} })); throw new Error("cleanup blocker did not expose a process group id"); } const processGroupId = blocker.pid; - const blockerClosed = new Promise((resolve) => blocker.once("close", () => resolve())); + const blockerClosed = new Promise((resolve) => { + blocker.once("close", () => resolve()); + }); const events: string[] = []; try { diff --git a/test/scripts/run-oxlint.test.ts b/test/scripts/run-oxlint.test.ts index 78d66e6f59a3..e8c250287ea1 100644 --- a/test/scripts/run-oxlint.test.ts +++ b/test/scripts/run-oxlint.test.ts @@ -140,7 +140,9 @@ async function waitFor(predicate: () => boolean, timeoutMs: number): Promise setTimeout(resolvePoll, 5)); + await new Promise((resolvePoll) => { + setTimeout(resolvePoll, 5); + }); } throw new Error("condition was not met before timeout"); }