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"); }