diff --git a/.github/codex/prompts/mantis-telegram-desktop-proof.md b/.github/codex/prompts/mantis-telegram-desktop-proof.md
index f3c380848416..3bc67f4d2aad 100644
--- a/.github/codex/prompts/mantis-telegram-desktop-proof.md
+++ b/.github/codex/prompts/mantis-telegram-desktop-proof.md
@@ -87,8 +87,9 @@ Iterate within the three-attempt budget; all attempts remain recorded.
Build `mantis-evidence.json` with
`scripts/mantis/build-telegram-desktop-proof-evidence.mts` as before, using each
lane's generated `telegram-user-crabbox-session-summary.json`. Edit only the
-human summary/expected wording. A failure or block sets `comparison.pass: false`
-and names the concrete product defect or missing primitive.
+human summary/expected wording. Name the concrete product defect or missing
+primitive when a lane fails or blocks; the workflow derives the outcome from
+trusted lane facts.
```bash
node --import tsx scripts/mantis/build-telegram-desktop-proof-evidence.mts \
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ecd70ba1a6d5..2314f5863172 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -87,6 +87,9 @@ jobs:
docs_only: ${{ steps.manifest.outputs.docs_only }}
docs_changed: ${{ steps.manifest.outputs.docs_changed }}
run_node: ${{ steps.manifest.outputs.run_node }}
+ # docker-seed-e2e-contract-v1: frozen targets without this marker skip the lane.
+ run_docker_seed_e2e: ${{ steps.manifest.outputs.run_docker_seed_e2e }}
+ docker_seed_lanes: ${{ steps.manifest.outputs.docker_seed_lanes }}
run_macos: ${{ steps.manifest.outputs.run_macos }}
run_android: ${{ steps.manifest.outputs.run_android }}
run_skills_python: ${{ steps.manifest.outputs.run_skills_python }}
@@ -636,6 +639,7 @@ jobs:
: "";
const supportsOpenClawKitTests = targetWorkflow.includes("openclawkit-tests-contract-v1");
const supportsCurrentAndroidCi = targetWorkflow.includes("android-ci-contract-v2");
+ const supportsDockerSeedE2e = targetWorkflow.includes("docker-seed-e2e-contract-v1");
const useCompatibleAndroidCi = compatibilityTarget && !supportsCurrentAndroidCi;
const supportsFormatCheck =
targetWorkflow.split("pnpm format:check").length - 1 >= 2;
@@ -671,6 +675,13 @@ jobs:
}
const compactPullRequest = isCanonicalRepository && eventName === "pull_request";
+ const dockerSeedLanes =
+ compactPullRequest &&
+ changedPaths &&
+ supportsDockerSeedE2e &&
+ typeof changedNodeTestPlan.resolveChangedDockerSeedLanes === "function"
+ ? changedNodeTestPlan.resolveChangedDockerSeedLanes(changedPaths)
+ : [];
// Canonical pushes also use compact bins: 80+ single-group jobs
// drain the runner pool for minutes, and per-shard check names on
// main have no branch-protection consumers. Dispatch (release
@@ -808,6 +819,8 @@ jobs:
docs_only: docsOnly,
docs_changed: docsChanged,
run_node: runNode,
+ run_docker_seed_e2e: dockerSeedLanes.length > 0,
+ docker_seed_lanes: dockerSeedLanes.join(" "),
run_macos: runMacos,
run_android: runAndroid,
run_skills_python: runSkillsPython,
@@ -4060,6 +4073,33 @@ jobs:
;;
esac
+ docker-seed-e2e:
+ permissions:
+ contents: read
+ name: docker-seed-e2e
+ needs: [preflight]
+ if: needs.preflight.outputs.run_docker_seed_e2e == 'true'
+ runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && github.run_attempt > 1) && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.event_name == 'pull_request' && (github.run_attempt > 1 || github.event.pull_request.head.repo.full_name != github.repository)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
+ timeout-minutes: 60
+ steps:
+ - *linux_node_checkout_step
+ - name: Setup Node environment
+ uses: ./.github/actions/setup-node-env
+ with:
+ node-version: "24.x"
+ install-bun: "false"
+ dependency-cache: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'false' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false') }}
+ use-actions-cache: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'true' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true') }}
+
+ - name: Run changed Docker seed owner lanes
+ env:
+ OPENCLAW_DOCKER_ALL_LANES: ${{ needs.preflight.outputs.docker_seed_lanes }}
+ OPENCLAW_DOCKER_ALL_LIVE_MODE: skip
+ OPENCLAW_DOCKER_E2E_ALLOW_UNRELEASED_CHANGELOG: "1"
+ OPENCLAW_DOCKER_ALL_PARALLELISM: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && github.event_name == 'pull_request' && github.run_attempt == 1 && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association) && 3 || 1 }}
+ OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && github.event_name == 'pull_request' && github.run_attempt == 1 && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association) && 3 || 1 }}
+ run: pnpm test:docker:all
+
ci-gate:
permissions:
contents: read
@@ -4092,6 +4132,7 @@ jobs:
- macos-swift
- ios-build
- android
+ - docker-seed-e2e
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04
timeout-minutes: 5
@@ -4128,6 +4169,7 @@ jobs:
macos-swift=${{ needs.macos-swift.result }}
ios-build=${{ needs.ios-build.result }}
android=${{ needs.android.result }}
+ docker-seed-e2e=${{ needs.docker-seed-e2e.result }}
run: |
set -euo pipefail
diff --git a/.github/workflows/mantis-telegram-desktop-proof.yml b/.github/workflows/mantis-telegram-desktop-proof.yml
index 520ed419f220..1079e42e5524 100644
--- a/.github/workflows/mantis-telegram-desktop-proof.yml
+++ b/.github/workflows/mantis-telegram-desktop-proof.yml
@@ -740,21 +740,23 @@ jobs:
sudo install -m 0755 "$tdlib_dir/tdlib-v1.8.0-linux-x64/lib/libtdjson.so" /usr/local/lib/libtdjson.so
# The Convex credential is the real mutex for the shared Telegram account:
# a concurrent holder fails this acquire, so no separate run-level lock is
- # needed. Retry while another run finishes, then fail with a clear reason.
+ # needed. Observed 2026-08: a complete proof held the account for 34m;
+ # four hours covers burst backfills while reserving roughly two hours
+ # of the job limit for proof, publication, and cleanup.
echo "lease_file=$credential_dir/lease.json" >> "$GITHUB_OUTPUT"
- deadline=$(( SECONDS + 15 * 60 ))
+ lease_deadline=$(( SECONDS + 4 * 60 * 60 ))
until node --import tsx scripts/e2e/telegram-user-credential.ts lease-restore \
--user-driver-dir "$credential_dir/user-driver" \
--desktop-workdir "$credential_dir/desktop" \
--lease-file "$credential_dir/lease.json" \
--payload-output "$credential_dir/payload.json" \
--credential-role ci; do
- if (( SECONDS >= deadline )); then
- echo "::error::The shared QA Telegram account is still leased by another run after 15 minutes." >&2
+ if (( SECONDS >= lease_deadline )); then
+ echo "::error::The shared QA Telegram account remained busy for four hours." >&2
exit 1
fi
- echo "Shared QA Telegram account is busy; retrying in 60s." >&2
- sleep 60
+ echo "Shared QA Telegram account is busy; retrying in 15s." >&2
+ sleep 15
done
chmod 0700 "$credential_dir" "$credential_dir/user-driver"
sut_credential_dir="/tmp/openclaw-mantis-sut-credential-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
@@ -975,20 +977,16 @@ jobs:
select((.summary | type) == "string" and (.summary | length) <= 4000) |
select((.comparison.baseline.expected | type) == "string" and (.comparison.baseline.expected | length) <= 1000) |
select((.comparison.candidate.expected | type) == "string" and (.comparison.candidate.expected | length) <= 1000) |
- select((.comparison.pass | type) == "boolean") |
- select((.comparison.candidate.fixed | type) == "boolean") |
{
summary,
baselineExpected: .comparison.baseline.expected,
- candidateExpected: .comparison.candidate.expected,
- comparisonPass: .comparison.pass,
- candidateFixed: .comparison.candidate.fixed
+ candidateExpected: .comparison.candidate.expected
}
' "$agent_manifest" > "$judgment"
baseline_status="$(sudo jq -r '.comparison.baseline.status' "$agent_manifest")"
candidate_status="$(sudo jq -r '.comparison.candidate.status' "$agent_manifest")"
- [[ "$baseline_status" == "pass" || "$baseline_status" == "fail" ]]
- [[ "$candidate_status" == "pass" || "$candidate_status" == "fail" ]]
+ [[ "$baseline_status" == "pass" || "$baseline_status" == "fail" || "$baseline_status" == "blocked" ]]
+ [[ "$candidate_status" == "pass" || "$candidate_status" == "fail" || "$candidate_status" == "blocked" ]]
copy_verified_artifacts() {
local lane="$1"
local facts_file="$2"
@@ -1047,6 +1045,18 @@ jobs:
(.artifacts.trimmedVideoCropped.bytes > 10000)
' "$verdict" >/dev/null
fi
+ if [[ "$fact_status" == "blocked" ]]; then
+ sudo jq -e '
+ (.blocked.name | type) == "string" and (.blocked.name | length) > 0 and (.blocked.name | length) <= 200 and
+ (.blocked.reason | type) == "string" and (.blocked.reason | length) > 0 and (.blocked.reason | length) <= 2000
+ ' "$verdict" >/dev/null
+ lane_status="blocked"
+ elif [[ "$fact_status" == "complete" ]]; then
+ [[ "$lane_status" == "pass" || "$lane_status" == "fail" ]]
+ else
+ lane_status="fail"
+ fi
+ printf -v "${lane}_status" '%s' "$lane_status"
if [[ "$pre_attestation_failure" != "true" ]]; then
sudo jq -e --arg lane "$lane" --arg sha "$expected_sha" \
'.lane == $lane and .sha == $sha' \
@@ -1104,15 +1114,16 @@ jobs:
sudo jq --slurpfile judgment "$judgment" '
.summary = $judgment[0].summary |
.comparison.baseline.expected = $judgment[0].baselineExpected |
- .comparison.candidate.expected = $judgment[0].candidateExpected |
- .comparison.pass = $judgment[0].comparisonPass |
- .comparison.candidate.fixed = $judgment[0].candidateFixed
+ .comparison.candidate.expected = $judgment[0].candidateExpected
' "$manifest" | sudo tee "$trusted_manifest" >/dev/null
sudo mv "$trusted_manifest" "$manifest"
jq -e '
- (.comparison.pass == false) or
- (.comparison.baseline.status == "pass" and .comparison.candidate.status == "pass")
+ (.comparison.outcome == "pass" and .comparison.pass == true and
+ .comparison.baseline.status == "pass" and .comparison.candidate.status == "pass") or
+ (.comparison.outcome == "blocked" and .comparison.pass == false and
+ (.comparison.baseline.status == "blocked" or .comparison.candidate.status == "blocked")) or
+ (.comparison.outcome == "fail" and .comparison.pass == false)
' "$manifest" >/dev/null
token="$(sudo jq -r '.sutToken' "$SUT_CREDENTIAL_DIR/credential.json")"
@@ -1223,7 +1234,7 @@ jobs:
echo "Mantis agent did not produce ${manifest}." >&2
exit 1
fi
- comparison_status="$(jq -r 'if .comparison.pass then "pass" else "fail" end' "$manifest")"
+ comparison_status="$(jq -er '.comparison.outcome | select(. == "pass" or . == "fail" or . == "blocked")' "$manifest")"
echo "comparison_status=${comparison_status}" >> "$GITHUB_OUTPUT"
- name: Upload Mantis Telegram desktop artifacts
@@ -1326,7 +1337,7 @@ jobs:
}
- name: Fail when Mantis Telegram desktop proof failed
- if: ${{ always() && steps.inspect.outputs.output_dir != '' && steps.inspect.outputs.comparison_status != 'pass' }}
+ if: ${{ always() && steps.inspect.outputs.output_dir != '' && steps.inspect.outputs.comparison_status != 'pass' && steps.inspect.outputs.comparison_status != 'blocked' }}
env:
COMPARISON_STATUS: ${{ steps.inspect.outputs.comparison_status }}
run: |
diff --git a/apps/macos/Sources/OpenClaw/CommandResolver.swift b/apps/macos/Sources/OpenClaw/CommandResolver.swift
index 6d0ba80828bd..e08e8304894d 100644
--- a/apps/macos/Sources/OpenClaw/CommandResolver.swift
+++ b/apps/macos/Sources/OpenClaw/CommandResolver.swift
@@ -47,19 +47,25 @@ enum CommandResolver {
return ["/bin/sh", "-c", script]
}
- static func projectRoot() -> URL {
- if let stored = AppDefaults.standard.string(forKey: projectRootDefaultsKey),
+ static func projectRoot(
+ defaults: UserDefaults = AppDefaults.standard,
+ profile: AppProfile = .current,
+ homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL
+ {
+ if let stored = defaults.string(forKey: projectRootDefaultsKey),
let url = expandPath(stored),
FileManager().fileExists(atPath: url.path)
{
return url
}
- let fallback = FileManager().homeDirectoryForCurrentUser
- .appendingPathComponent("Projects/openclaw")
+ if profile.isActive {
+ return profile.stateDirectoryURL(homeDirectory: homeDirectory)
+ }
+ let fallback = homeDirectory.appendingPathComponent("Projects/openclaw")
if FileManager().fileExists(atPath: fallback.path) {
return fallback
}
- return FileManager().homeDirectoryForCurrentUser
+ return homeDirectory
}
static func setProjectRoot(_ path: String) {
diff --git a/apps/macos/Sources/OpenClaw/ControlChannel.swift b/apps/macos/Sources/OpenClaw/ControlChannel.swift
index d8e940c97efd..422d69a59096 100644
--- a/apps/macos/Sources/OpenClaw/ControlChannel.swift
+++ b/apps/macos/Sources/OpenClaw/ControlChannel.swift
@@ -211,7 +211,7 @@ final class ControlChannel {
self.setStateThrottled(.connected)
PresenceReporter.shared.sendImmediate(reason: "connect")
} catch {
- let message = self.friendlyGatewayMessage(error)
+ let message = Self.friendlyGatewayMessage(error, configRoot: OpenClawConfigFile.loadDict())
self.setStateThrottled(.degraded(message))
}
}
@@ -237,7 +237,7 @@ final class ControlChannel {
self.setStateThrottled(.connected)
return payload
} catch {
- let message = self.friendlyGatewayMessage(error)
+ let message = Self.friendlyGatewayMessage(error, configRoot: OpenClawConfigFile.loadDict())
self.setStateThrottled(.degraded(message))
throw ControlChannelError.badResponse(message)
}
@@ -266,7 +266,7 @@ final class ControlChannel {
self.setStateThrottled(.connected)
return data
} catch {
- let message = self.friendlyGatewayMessage(error)
+ let message = Self.friendlyGatewayMessage(error, configRoot: OpenClawConfigFile.loadDict())
self.setStateThrottled(.degraded(message))
throw ControlChannelError.badResponse(message)
}
@@ -283,13 +283,13 @@ final class ControlChannel {
self.setStateThrottled(.connected)
return data
} catch {
- let message = self.friendlyGatewayMessage(error)
+ let message = Self.friendlyGatewayMessage(error, configRoot: OpenClawConfigFile.loadDict())
self.setStateThrottled(.degraded(message))
throw ControlChannelError.badResponse(message)
}
}
- private func friendlyGatewayMessage(_ error: Error) -> String {
+ static func friendlyGatewayMessage(_ error: Error, configRoot: [String: Any]) -> String {
// Map URLSession/WS errors into user-facing, actionable text.
if let ctrlErr = error as? ControlChannelError, let desc = ctrlErr.errorDescription {
return desc
@@ -299,12 +299,24 @@ final class ControlChannel {
return authIssue.statusMessage
}
+ let mode = ConnectionModeResolver.resolve(root: configRoot).mode
+ let transport = GatewayRemoteConfig.resolveTransportResolution(root: configRoot)
+ let localPort = GatewayEnvironment.gatewayPort()
+ let directURL = mode == .remote && transport.transport == .direct ? transport.directURL : nil
+ let endpoint = if let url = directURL, let host = url.host,
+ let port = GatewayRemoteConfig.defaultPort(for: url)
+ {
+ "\(host.contains(":") && !host.hasPrefix("[") ? "[" + host + "]" : host):\(port)"
+ } else {
+ "localhost:\(localPort)"
+ }
+
// If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it.
if let urlErr = error as? URLError,
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
{
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
- let tokenKey = CommandResolver.connectionModeIsRemote()
+ let tokenKey = mode == .remote
? "gateway.remote.token"
: "gateway.auth.token"
return
@@ -320,34 +332,37 @@ final class ControlChannel {
if nsError.domain == "Gateway",
nsError.localizedDescription.contains("hello failed (unexpected response)")
{
- let port = GatewayEnvironment.gatewayPort()
+ if directURL != nil {
+ return "Gateway handshake got non-gateway data on \(endpoint); check the Gateway URL and server."
+ }
return """
- Gateway handshake got non-gateway data on localhost:\(port).
+ Gateway handshake got non-gateway data on \(endpoint).
Another process is using that port or the SSH forward failed.
- Stop the local gateway/port-forward on \(port) and retry Remote mode.
+ Stop the local gateway/port-forward on \(localPort) and retry Remote mode.
"""
}
if let urlError = error as? URLError {
- let port = GatewayEnvironment.gatewayPort()
switch urlError.code {
case .cancelled:
- return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry."
+ return "Gateway connection was closed; start the gateway (\(endpoint)) and retry."
case .cannotFindHost, .cannotConnectToHost:
- let isRemote = CommandResolver.connectionModeIsRemote()
- if isRemote {
+ if directURL != nil {
+ return "Cannot reach gateway at \(endpoint); check the Gateway URL and remote gateway."
+ }
+ if mode == .remote {
return """
- Cannot reach gateway at localhost:\(port).
+ Cannot reach gateway at \(endpoint).
Remote mode uses an SSH tunnel—check the SSH target and that the tunnel is running.
"""
}
- return "Cannot reach gateway at localhost:\(port); ensure the gateway is running."
+ return "Cannot reach gateway at \(endpoint); ensure the gateway is running."
case .networkConnectionLost:
return "Gateway connection dropped; gateway likely restarted—retry."
case .timedOut:
- return "Gateway request timed out; check gateway on localhost:\(port)."
+ return "Gateway request timed out; check gateway on \(endpoint)."
case .notConnectedToInternet:
- if Self.isLikelyLocalNetworkPermissionBlock() {
+ if Self.isLikelyLocalNetworkPermissionBlock(configRoot: configRoot) {
return """
macOS is blocking OpenClaw Local Network access.
Allow OpenClaw in System Settings → Privacy & Security → Local Network, then relaunch the app.
@@ -360,8 +375,7 @@ final class ControlChannel {
}
if nsError.domain == "Gateway", nsError.code == 5 {
- let port = GatewayEnvironment.gatewayPort()
- return "Gateway request timed out; check the gateway process on localhost:\(port)."
+ return "Gateway request timed out; check the gateway process on \(endpoint)."
}
let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription
@@ -370,10 +384,9 @@ final class ControlChannel {
return "Gateway error: \(trimmed)"
}
- private static func isLikelyLocalNetworkPermissionBlock() -> Bool {
- let root = OpenClawConfigFile.loadDict()
- let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
- guard ConnectionModeResolver.resolve(root: root).mode == .remote,
+ private static func isLikelyLocalNetworkPermissionBlock(configRoot: [String: Any]) -> Bool {
+ let resolution = GatewayRemoteConfig.resolveTransportResolution(root: configRoot)
+ guard ConnectionModeResolver.resolve(root: configRoot).mode == .remote,
resolution.transport == .direct,
let url = resolution.directURL,
url.scheme?.lowercased() == "ws",
@@ -412,10 +425,10 @@ final class ControlChannel {
if mode == .remote {
do {
let port = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
- self.logger.info("control channel recovery ensured SSH tunnel port=\(port, privacy: .public)")
+ self.logger.info("control channel recovery ensured remote endpoint port=\(port, privacy: .public)")
} catch {
self.logger.error(
- "control channel recovery tunnel failed \(error.localizedDescription, privacy: .public)")
+ "control channel remote endpoint failed \(error.localizedDescription, privacy: .public)")
}
}
diff --git a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift
index 6e445eb9a159..fe6d0107a5b5 100644
--- a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift
@@ -22,6 +22,30 @@ import Testing
return (tmp, pnpmPath)
}
+ @Test func `named profiles do not inherit the default development checkout`() throws {
+ let home = try makeTempDirForTests()
+ let checkout = home.appendingPathComponent("Projects/openclaw")
+ try FileManager.default.createDirectory(at: checkout, withIntermediateDirectories: true)
+ let defaults = self.makeDefaults()
+ let defaultProfile = AppProfile(environment: [:])
+ let namedProfile = AppProfile(environment: ["OPENCLAW_PROFILE": "isolated"])
+
+ #expect(CommandResolver.projectRoot(
+ defaults: defaults,
+ profile: defaultProfile,
+ homeDirectory: home).path == checkout.path)
+ #expect(CommandResolver.projectRoot(
+ defaults: defaults,
+ profile: namedProfile,
+ homeDirectory: home).path == home.appendingPathComponent(".openclaw-isolated").path)
+
+ defaults.set(checkout.path, forKey: "openclaw.gatewayProjectRootPath")
+ #expect(CommandResolver.projectRoot(
+ defaults: defaults,
+ profile: namedProfile,
+ homeDirectory: home).path == checkout.path)
+ }
+
@Test func `prefers open claw binary`() async throws {
let defaults = self.makeLocalDefaults()
diff --git a/apps/macos/Tests/OpenClawIPCTests/ControlChannelStateDebouncerTests.swift b/apps/macos/Tests/OpenClawIPCTests/ControlChannelStateDebouncerTests.swift
index 9c07993855dd..2afe5a195797 100644
--- a/apps/macos/Tests/OpenClawIPCTests/ControlChannelStateDebouncerTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/ControlChannelStateDebouncerTests.swift
@@ -4,7 +4,7 @@ import Testing
struct ControlChannelStateDebouncerTests {
@Test func `terminal states apply immediately`() {
- let start = Date(timeIntervalSince1970: 1_000)
+ let start = Date(timeIntervalSince1970: 1000)
var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start)
let degradedDelay = debouncer.delayBeforeApplying(
@@ -27,7 +27,7 @@ struct ControlChannelStateDebouncerTests {
}
@Test func `nonterminal states are debounced within interval`() {
- let start = Date(timeIntervalSince1970: 1_000)
+ let start = Date(timeIntervalSince1970: 1000)
var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start)
let soonDelay = debouncer.delayBeforeApplying(
@@ -45,7 +45,7 @@ struct ControlChannelStateDebouncerTests {
}
@Test func `deferred apply resets debounce window`() {
- let start = Date(timeIntervalSince1970: 1_000)
+ let start = Date(timeIntervalSince1970: 1000)
var debouncer = ControlChannelStateDebouncer(interval: 0.5, lastAppliedAt: start)
debouncer.recordDeferredApply(at: start.addingTimeInterval(0.5))
@@ -58,3 +58,134 @@ struct ControlChannelStateDebouncerTests {
#expect(abs((delayAfterDeferredUpdate ?? 0) - 0.3) < 0.001)
}
}
+
+@MainActor
+struct ControlChannelGatewayMessageTests {
+ @Test(arguments: [
+ URLError.Code.cannotFindHost,
+ URLError.Code.cannotConnectToHost,
+ URLError.Code.cancelled,
+ URLError.Code.timedOut,
+ ])
+ func `direct gateway failures identify their actual endpoint`(code: URLError.Code) {
+ let root: [String: Any] = [
+ "gateway": [
+ "mode": "remote",
+ "remote": [
+ "transport": "direct",
+ "url": "ws://127.0.0.1:42674",
+ ],
+ ],
+ ]
+
+ let message = ControlChannel.friendlyGatewayMessage(URLError(code), configRoot: root)
+
+ #expect(message.contains("127.0.0.1:42674"))
+ #expect(!message.contains("SSH"))
+ }
+
+ @Test func `direct gateway diagnostics never expose URL credentials`() {
+ let root: [String: Any] = [
+ "gateway": [
+ "mode": "remote",
+ "remote": [
+ "transport": "direct",
+ "url": "wss://user:secret@gateway.example:9443/path?token=private",
+ ],
+ ],
+ ]
+
+ let message = ControlChannel.friendlyGatewayMessage(
+ URLError(.cannotConnectToHost),
+ configRoot: root)
+
+ #expect(message.contains("gateway.example:9443"))
+ #expect(!message.contains("secret"))
+ #expect(!message.contains("private"))
+ }
+
+ @Test func `direct secure gateway diagnostics use the default TLS port`() {
+ let root: [String: Any] = [
+ "gateway": [
+ "mode": "remote",
+ "remote": [
+ "transport": "direct",
+ "url": "wss://gateway.example",
+ ],
+ ],
+ ]
+
+ let message = ControlChannel.friendlyGatewayMessage(
+ URLError(.cannotConnectToHost),
+ configRoot: root)
+
+ #expect(message.contains("gateway.example:443"))
+ }
+
+ @Test func `direct IPv6 gateway diagnostics bracket the endpoint host`() {
+ let root: [String: Any] = [
+ "gateway": [
+ "mode": "remote",
+ "remote": [
+ "transport": "direct",
+ "url": "wss://[fd12:3456:789a::1]:9443",
+ ],
+ ],
+ ]
+
+ let message = ControlChannel.friendlyGatewayMessage(
+ URLError(.cannotConnectToHost),
+ configRoot: root)
+
+ #expect(message.contains("[fd12:3456:789a::1]:9443"))
+ }
+
+ @Test func `SSH gateway failures preserve tunnel recovery guidance`() {
+ let root: [String: Any] = [
+ "gateway": [
+ "mode": "remote",
+ "remote": ["transport": "ssh"],
+ ],
+ ]
+
+ let message = ControlChannel.friendlyGatewayMessage(
+ URLError(.cannotConnectToHost),
+ configRoot: root)
+
+ #expect(message.contains("localhost:"))
+ #expect(message.contains("SSH tunnel"))
+ }
+
+ @Test func `direct gateway handshake failures identify the remote endpoint`() {
+ let root: [String: Any] = [
+ "gateway": [
+ "mode": "remote",
+ "remote": [
+ "transport": "direct",
+ "url": "wss://gateway.example:9443",
+ ],
+ ],
+ ]
+ let error = NSError(
+ domain: "Gateway",
+ code: 1,
+ userInfo: [NSLocalizedDescriptionKey: "hello failed (unexpected response)"])
+
+ let message = ControlChannel.friendlyGatewayMessage(error, configRoot: root)
+
+ #expect(message.contains("gateway.example:9443"))
+ #expect(!message.contains("SSH"))
+ }
+
+ @Test func `local gateway failures preserve local recovery guidance`() {
+ let root: [String: Any] = ["gateway": ["mode": "local"]]
+
+ let message = ControlChannel.friendlyGatewayMessage(
+ URLError(.cannotConnectToHost),
+ configRoot: root)
+
+ #expect(message.contains("localhost:"))
+ #expect(message.contains("ensure the gateway is running"))
+ #expect(!message.contains("SSH"))
+ }
+}
diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md
index a99cae74dc30..4dde7f1fe924 100644
--- a/docs/automation/cron-jobs.md
+++ b/docs/automation/cron-jobs.md
@@ -850,7 +850,7 @@ Disable automations: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
- `cron.sessionRetention` (default `24h`, `false` or `"0h"` disables) prunes isolated run-session entries. Run history keeps the newest 2000 terminal rows per job; lost rows retain their 24-hour cleanup window.
+ `cron.sessionRetention` (default `24h`, `false` or `"0h"` disables) prunes isolated run-session entries. Terminal run history is retained for 7 days (`lost` rows for 24 hours), with the newest 2000 rows per job and history class enforced as an additional ceiling.
On upgrade, run `openclaw doctor --fix` to import historical `~/.openclaw/cron/jobs.json`, `jobs-state.json`, `jobs-quarantine.json`, and `runs/*.jsonl` files into SQLite and archive the originals with a `.migrated` suffix. Malformed job rows remain recoverable in SQLite while valid jobs keep running.
diff --git a/docs/ci.md b/docs/ci.md
index 5d6c66795dbf..3cc52aa816d2 100644
--- a/docs/ci.md
+++ b/docs/ci.md
@@ -42,6 +42,7 @@ dispatch.
| `checks-fast-contracts-plugins-*` | Two weighted plugin contract shards | Node-relevant changes |
| `checks-fast-contracts-channels-*` | Two weighted channel contract shards | Node-relevant changes |
| `checks-node-*` | Changed-target Node tests on pull requests; compact integration shards on `main`; metadata-complete compact fallback on broad PRs; full named shards on manual and release runs | Node-relevant changes |
+| `docker-seed-e2e` | One Docker scheduler job for the executable `mcp-channels`, `cron-mcp-cleanup`, and `mcp-code-mode-gateway` owner lanes | PR changes to their seed helpers or CI gate owners |
| `check-*` | Sharded main local gate equivalent: guards, transient npm-lock validation, bundled-channel config metadata, prod types, lint, dependencies, test types | Node-relevant changes |
| `check-additional-*` | Boundary check stripes (including prompt snapshot drift), session accessor/transcript reader/SQLite transaction boundaries, extension lint groups, package boundary compile/canary, and runtime topology architecture; the pure-reporting plugin SDK API diff runs on manual and release dispatches only | Node-relevant changes |
| `checks-node-compat-node22` | Node 22 compatibility build and smoke lane | Full Release Validation and manual dispatches only |
@@ -57,6 +58,14 @@ dispatch.
| `test-performance-agent` | Separate workflow: daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch |
| `openclaw-performance` | Separate workflow: daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.6 live lanes | Scheduled and manual dispatch |
+The rare path-triggered `docker-seed-e2e` job selects only the executable
+owners of changed seed helpers and runs them through one scheduler invocation.
+Trusted same-repository pull requests use one 16-vCPU Blacksmith runner with
+main and tail parallelism set to 3; GitHub-hosted, fork, and retry paths run the
+same selected lanes serially. The job is part of `openclaw/ci-gate`. It adds at
+most one runner registration during an affected pull-request window and adds no
+registrations for unrelated pull requests.
+
Standalone Periphery workflows enforce zero dead-code findings for the iOS and macOS apps. The shared OpenClawKit workflow scans both consumers in parallel and reports a declaration only when Periphery emits the same Swift USR from both builds. Its generated `OpenClawProtocol/GatewayModels.swift` schema contract is retained as generator-owned code rather than treated as app-local dead code.
## Fail-fast order
diff --git a/docs/cli/cron.md b/docs/cli/cron.md
index ef038c8728d6..aca6580503c7 100644
--- a/docs/cli/cron.md
+++ b/docs/cli/cron.md
@@ -233,7 +233,7 @@ The scheduler does not classify final-output prose or approval-looking refusal p
Retention behavior:
- `cron.sessionRetention` (default `24h`, or `false` to disable; a zero duration such as `"0h"` also disables) prunes completed isolated run sessions.
-- Run history keeps the newest 2000 terminal rows per job. Lost rows retain the standard 24-hour lost-task cleanup window.
+- Terminal run history is retained for 7 days (`lost` rows for 24 hours), with the newest 2000 rows per job and history class enforced as an additional ceiling.
## Migrating older jobs
diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md
index a61f0cf0e667..db20a91a78f3 100644
--- a/docs/cli/sessions.md
+++ b/docs/cli/sessions.md
@@ -215,8 +215,10 @@ openclaw sessions cleanup --json
- Scope note: `openclaw sessions cleanup` maintains session stores,
transcripts, trajectory rows, and legacy trajectory sidecars. It does not
- prune cron run history, which automatically keeps the newest 2000 rows per job
- ([Cron configuration](/automation/cron-jobs#configuration)).
+ prune cron run history. Task maintenance retains terminal cron history for 7
+ days (`lost` rows for 24 hours) and enforces the newest 2000 rows per job and
+ history class as an additional ceiling ([Task maintenance](/automation/tasks#automatic-maintenance),
+ [Cron configuration](/automation/cron-jobs#configuration)).
- Cleanup also prunes unreferenced legacy/archive transcript artifacts,
compaction checkpoints, and trajectory sidecars older than
`session.maintenance.pruneAfter`; artifacts still referenced by SQLite
diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md
index 075c8fc1affa..eab1e62ba652 100644
--- a/docs/gateway/configuration-reference.md
+++ b/docs/gateway/configuration-reference.md
@@ -1646,7 +1646,7 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
- `enabled`: execute stored automation jobs (default: `true`). Set `false` to pause all automation execution without deleting jobs.
- `triggers.enabled`: run event-driven automation triggers (default: `true`). Set `false` to disable condition triggers, script payloads, and stream schedules.
- `sessionRetention`: how long to keep completed isolated automation run sessions before pruning SQLite session rows. Also controls cleanup of archived deleted automation transcripts. Default: `24h`; set `false` or a zero duration such as `"0h"` to disable (negative durations are invalid).
-- Run history automatically keeps the newest 2000 terminal rows per job. Lost rows retain their 24-hour cleanup window.
+- Terminal run history is retained for 7 days (`lost` rows for 24 hours), with the newest 2000 rows per job and history class enforced as an additional ceiling.
- `webhookToken`: bearer token used for automation webhook POST delivery (`delivery.mode = "webhook"`), if omitted no auth header is sent.
- `webhookSsrfPolicy`: shared outbound SSRF policy for primary, completion, failure-destination, and failure-alert webhooks. Private/internal targets are blocked when omitted. Prefer exact `allowedHostnames`; use `dangerouslyAllowPrivateNetwork: true` only for trusted private-network receivers. The narrow fake-IP proxy flags are `allowRfc2544BenchmarkRange` and `allowIpv6UniqueLocalRange`.
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index 9b06ff900a92..14bcdc3a1fe8 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -415,7 +415,7 @@ candidate contains a redacted secret placeholder such as `***` or `[redacted]`.
```
- `sessionRetention`: prune completed isolated run sessions from SQLite session rows (default `24h`; set `false` or a zero duration such as `"0h"` to disable).
- - Run history automatically keeps the newest 2000 terminal rows per job; lost rows retain their 24-hour cleanup window.
+ - Terminal run history is retained for 7 days (`lost` rows for 24 hours), with the newest 2000 rows per job and history class enforced as an additional ceiling.
- See [Cron jobs](/automation/cron-jobs) for feature overview and CLI examples.
diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md
index 070183abe099..70ba64f368c1 100644
--- a/docs/reference/session-management-compaction.md
+++ b/docs/reference/session-management-compaction.md
@@ -110,7 +110,7 @@ artifacts before importing.
Isolated cron runs create their own session entries/transcripts with dedicated retention:
- `cron.sessionRetention` (default `"24h"`) prunes old isolated cron run sessions from the store; `false` or a zero duration such as `"0h"` disables.
-- Run history keeps the newest 2000 terminal rows per cron job. Lost rows retain their 24-hour cleanup window.
+- Terminal run history is retained for 7 days (`lost` rows for 24 hours), with the newest 2000 rows per job and history class enforced as an additional ceiling.
When cron force-creates a new isolated run session, it sanitizes the previous `cron:` session entry before writing the new row: it carries safe preferences (thinking/fast/verbose/reasoning settings, labels, display name) and explicit user-selected model/auth overrides, but drops ambient conversation context (channel/group routing, send/queue policy, elevation, origin, ACP runtime binding) so a fresh isolated run cannot inherit stale delivery or runtime authority from an older run.
diff --git a/extensions/browser/plugin-registration.node-host-laziness.test.ts b/extensions/browser/plugin-registration.node-host-laziness.test.ts
new file mode 100644
index 000000000000..ced37c494ac8
--- /dev/null
+++ b/extensions/browser/plugin-registration.node-host-laziness.test.ts
@@ -0,0 +1,27 @@
+import { expect, it, vi } from "vitest";
+
+const cleanupMocks = vi.hoisted(() => ({
+ ensureBrowserProxyUploadCleanup: vi.fn(async () => undefined),
+}));
+
+vi.mock("./register.runtime.js", () => {
+ throw new Error("node-host availability must not load the broad browser runtime");
+});
+
+vi.mock("./src/browser-proxy-upload-cleanup.runtime.js", () => ({
+ ensureBrowserProxyUploadCleanup: cleanupMocks.ensureBrowserProxyUploadCleanup,
+}));
+
+const { browserPluginNodeHostCommands } = await import("./plugin-registration.js");
+
+it("starts node-host upload cleanup without loading the broad browser runtime", async () => {
+ const uploadCommand = browserPluginNodeHostCommands.find(
+ (command) => command.command === "browser.proxy.upload.v1",
+ );
+
+ uploadCommand?.watchAvailability?.({ config: {}, env: {} }, vi.fn());
+
+ await vi.waitFor(() => {
+ expect(cleanupMocks.ensureBrowserProxyUploadCleanup).toHaveBeenCalledOnce();
+ });
+});
diff --git a/extensions/browser/plugin-registration.ts b/extensions/browser/plugin-registration.ts
index f993dd10afce..35e77fd4d912 100644
--- a/extensions/browser/plugin-registration.ts
+++ b/extensions/browser/plugin-registration.ts
@@ -45,6 +45,9 @@ const logger = createSubsystemLogger("browser");
const loadBrowserRegistrationRuntimeModule = createLazyRuntimeModule(
() => import("./register.runtime.js"),
);
+const loadBrowserUploadCleanupRuntimeModule = createLazyRuntimeModule(
+ () => import("./src/browser-proxy-upload-cleanup.runtime.js"),
+);
function deriveChatTypeFromSessionKey(
sessionKey: string | undefined,
@@ -201,7 +204,7 @@ function createBrowserProxyNodeHostCommand(command: string): OpenClawPluginNodeH
...(command === BROWSER_PROXY_UPLOAD_COMMAND
? {
watchAvailability: () => {
- void loadBrowserRegistrationRuntimeModule()
+ void loadBrowserUploadCleanupRuntimeModule()
.then(({ ensureBrowserProxyUploadCleanup }) => ensureBrowserProxyUploadCleanup())
.catch((error: unknown) => {
logger.warn(`browser proxy upload cleanup startup failed: ${String(error)}`);
diff --git a/extensions/browser/register.runtime.ts b/extensions/browser/register.runtime.ts
index a65baac6a603..a55e9818d4de 100644
--- a/extensions/browser/register.runtime.ts
+++ b/extensions/browser/register.runtime.ts
@@ -3,7 +3,6 @@
* registration lazy-load these exports when browser runtime behavior is needed.
*/
export { createBrowserTool } from "./src/browser-tool.js";
-export { ensureBrowserProxyUploadCleanup } from "./src/browser-proxy-upload.js";
export { handleBrowserGatewayRequest } from "./src/gateway/browser-request.js";
export { runBrowserProxyCommand } from "./src/node-host/invoke-browser.js";
export { createBrowserPluginService } from "./src/plugin-service.js";
diff --git a/extensions/browser/src/browser-proxy-upload-cleanup.runtime.ts b/extensions/browser/src/browser-proxy-upload-cleanup.runtime.ts
new file mode 100644
index 000000000000..bbbec63b5bd8
--- /dev/null
+++ b/extensions/browser/src/browser-proxy-upload-cleanup.runtime.ts
@@ -0,0 +1 @@
+export { ensureBrowserProxyUploadCleanup } from "./browser-proxy-upload.js";
diff --git a/extensions/qa-lab/src/confidence-report.test.ts b/extensions/qa-lab/src/confidence-report.test.ts
index 19b4235db0fb..1d28e70e25a0 100644
--- a/extensions/qa-lab/src/confidence-report.test.ts
+++ b/extensions/qa-lab/src/confidence-report.test.ts
@@ -37,6 +37,38 @@ describe("qa confidence report", () => {
return filePath;
}
+ async function buildStrictSuiteReport(payload: Record, withBackfill = false) {
+ await writeJson("report-only/qa-suite-summary.json", payload);
+ const lanes: QaConfidenceManifest["lanes"] = [
+ {
+ id: "report-only",
+ title: "Report-only",
+ kind: "qa-suite-summary",
+ artifact: "report-only/qa-suite-summary.json",
+ required: true,
+ ...(withBackfill ? { skipBackfillLane: "backfill" } : {}),
+ },
+ ];
+ if (withBackfill) {
+ await writeJson("backfill/qa-suite-summary.json", {
+ counts: { total: 1, passed: 1, failed: 0, skipped: 0 },
+ });
+ lanes.push({
+ id: "backfill",
+ title: "Passing backfill",
+ kind: "qa-suite-summary",
+ artifact: "backfill/qa-suite-summary.json",
+ required: true,
+ });
+ }
+ return buildQaConfidenceReport({
+ manifest: { version: 1, profile: "confidence-regression", lanes },
+ artifactRoot: tempRoot,
+ strictZeroUnknowns: true,
+ strictGlobalPass: true,
+ });
+ }
+
it("passes strict zero-unknowns when every lane passes or has an allowed blocked verdict", async () => {
await writeJson("tool-defaults/qa-suite-summary.json", {
counts: { total: 20, passed: 18, skipped: 2, failed: 0 },
@@ -276,31 +308,11 @@ describe("qa confidence report", () => {
});
it("fails strict global pass for skipped suite rows until a backfill lane passes", async () => {
- await writeJson("report-only/qa-suite-summary.json", {
+ const report = await buildStrictSuiteReport({
counts: { total: 3, passed: 2, skipped: 1, failed: 0 },
scenarios: [],
});
- const report = await buildQaConfidenceReport({
- manifest: {
- version: 1,
- profile: "codex-100",
- lanes: [
- {
- id: "report-only",
- title: "Report-only",
- kind: "qa-suite-summary",
- artifact: "report-only/qa-suite-summary.json",
- required: true,
- },
- ],
- },
- artifactRoot: tempRoot,
- strictZeroUnknowns: true,
- strictGlobalPass: true,
- generatedAt: "2026-05-12T00:00:00.000Z",
- });
-
expect(report.zeroUnknowns).toBe(true);
expect(report.globalPass).toBe(false);
expect(report.failures).toEqual([
@@ -308,6 +320,82 @@ describe("qa confidence report", () => {
]);
});
+ it.each([
+ ["count-backed", "skip"],
+ ["count-backed", "skipped"],
+ ["legacy", "skip"],
+ ["legacy", "skipped"],
+ ["unverified-pass-count", "skip"],
+ ["unverified-pass-count", "skipped"],
+ ] as const)(
+ "rejects %s suites containing only %s scenarios despite a passing backfill",
+ async (format, skippedStatus) => {
+ const report = await buildStrictSuiteReport(
+ {
+ ...(format === "count-backed"
+ ? { counts: { total: 1, passed: 0, failed: 0, skipped: 1 } }
+ : format === "unverified-pass-count"
+ ? { counts: { passed: 1, failed: 0 } }
+ : {}),
+ scenarios: [{ name: "never executed", status: skippedStatus }],
+ },
+ true,
+ );
+
+ expect(report.pass).toBe(false);
+ expect(report.globalPass).toBe(false);
+ expect(report.lanes[0]).toMatchObject({ status: "unknown" });
+ expect(report.lanes[0]?.details).toContain("no executed scenarios");
+ expect(report.lanes[1]).toMatchObject({ status: "pass" });
+ },
+ );
+
+ it.each([
+ ["skip", undefined],
+ ["skipped", undefined],
+ ["count-reported skip", 1],
+ ] as const)(
+ "requires a passing backfill for legacy suites containing a pass and %s",
+ async (skippedStatus, explicitSkippedCount) => {
+ const artifact = {
+ ...(explicitSkippedCount === undefined
+ ? {}
+ : { counts: { skipped: explicitSkippedCount } }),
+ scenarios: [
+ { name: "executed", status: "pass" },
+ ...(explicitSkippedCount === undefined
+ ? [{ name: "not executed", status: skippedStatus }]
+ : []),
+ ],
+ };
+
+ for (const hasBackfill of [false, true]) {
+ const report = await buildStrictSuiteReport(artifact, hasBackfill);
+
+ expect(report.pass).toBe(hasBackfill);
+ expect(report.globalPass).toBe(hasBackfill);
+ expect(report.lanes[0]).toMatchObject({ status: "pass", skippedCount: 1 });
+ if (hasBackfill) {
+ expect(report.lanes[0]).toMatchObject({ skipBackfilled: true });
+ } else {
+ expect(report.failures).toEqual([
+ "report-only has 1 skipped row(s) with no passing backfill lane",
+ ]);
+ }
+ }
+ },
+ );
+
+ it("accepts a positive count-only suite without scenario rows", async () => {
+ const report = await buildStrictSuiteReport({
+ counts: { total: 1, passed: 1, failed: 0, skipped: 0 },
+ });
+
+ expect(report.pass).toBe(true);
+ expect(report.globalPass).toBe(true);
+ expect(report.lanes[0]).toMatchObject({ status: "pass" });
+ });
+
it("infers skipped suite rows from totals and scenario status", async () => {
for (const [artifact, expectedDetail] of [
[{ counts: { total: 3, passed: 2, failed: 0 }, scenarios: [] }, "counts.skipped=1"],
@@ -322,27 +410,7 @@ describe("qa confidence report", () => {
"counts.skipped=1",
],
] as const) {
- await writeJson("report-only/qa-suite-summary.json", artifact);
-
- const report = await buildQaConfidenceReport({
- manifest: {
- version: 1,
- profile: "codex-100",
- lanes: [
- {
- id: "report-only",
- title: "Report-only",
- kind: "qa-suite-summary",
- artifact: "report-only/qa-suite-summary.json",
- required: true,
- },
- ],
- },
- artifactRoot: tempRoot,
- strictZeroUnknowns: true,
- strictGlobalPass: true,
- generatedAt: "2026-05-12T00:00:00.000Z",
- });
+ const report = await buildStrictSuiteReport(artifact);
expect(report.globalPass).toBe(false);
expect(report.failures).toEqual([
@@ -369,27 +437,7 @@ describe("qa confidence report", () => {
"unsupported non-pass status",
],
] as const) {
- await writeJson("report-only/qa-suite-summary.json", artifact);
-
- const report = await buildQaConfidenceReport({
- manifest: {
- version: 1,
- profile: "codex-100",
- lanes: [
- {
- id: "report-only",
- title: "Report-only",
- kind: "qa-suite-summary",
- artifact: "report-only/qa-suite-summary.json",
- required: true,
- },
- ],
- },
- artifactRoot: tempRoot,
- strictZeroUnknowns: true,
- strictGlobalPass: true,
- generatedAt: "2026-05-12T00:00:00.000Z",
- });
+ const report = await buildStrictSuiteReport(artifact);
expect(report.pass).toBe(false);
expect(report.globalPass).toBe(false);
@@ -468,49 +516,17 @@ describe("qa confidence report", () => {
});
it("passes strict global pass when skipped suite rows are backfilled by a passing lane", async () => {
- await writeJson("report-only/qa-suite-summary.json", {
- counts: { total: 3, passed: 2, skipped: 1, failed: 0 },
- scenarios: [],
- });
- await writeJson("live-backfill/qa-suite-summary.json", {
- counts: { total: 1, passed: 1, skipped: 0, failed: 0 },
- scenarios: [],
- });
-
- const report = await buildQaConfidenceReport({
- manifest: {
- version: 1,
- profile: "codex-100",
- lanes: [
- {
- id: "report-only",
- title: "Report-only",
- kind: "qa-suite-summary",
- artifact: "report-only/qa-suite-summary.json",
- required: true,
- skipBackfillLane: "live-backfill",
- },
- {
- id: "live-backfill",
- title: "Live backfill",
- kind: "qa-suite-summary",
- artifact: "live-backfill/qa-suite-summary.json",
- required: true,
- },
- ],
- },
- artifactRoot: tempRoot,
- strictZeroUnknowns: true,
- strictGlobalPass: true,
- generatedAt: "2026-05-12T00:00:00.000Z",
- });
+ const report = await buildStrictSuiteReport(
+ { counts: { total: 3, passed: 2, skipped: 1, failed: 0 }, scenarios: [] },
+ true,
+ );
expect(report.pass).toBe(true);
expect(report.zeroUnknowns).toBe(true);
expect(report.globalPass).toBe(true);
expect(report.lanes[0]).toMatchObject({
skippedCount: 1,
- skipBackfillLane: "live-backfill",
+ skipBackfillLane: "backfill",
skipBackfilled: true,
});
});
diff --git a/extensions/qa-lab/src/confidence-report.ts b/extensions/qa-lab/src/confidence-report.ts
index 6e9d2be2ccb0..c51b5077617c 100644
--- a/extensions/qa-lab/src/confidence-report.ts
+++ b/extensions/qa-lab/src/confidence-report.ts
@@ -390,9 +390,8 @@ function evaluateQaSuiteSummary(payload: unknown): QaConfidenceLaneEvaluation {
const failedCount = readCount(counts?.failed);
const explicitSkippedCount = readCount(counts?.skipped);
const scenarios = Array.isArray(payload.scenarios) ? payload.scenarios : undefined;
- const failedScenarios = scenarios?.filter(
- (scenario) => isRecord(scenario) && scenario.status === "fail",
- );
+ const failedScenarioCount =
+ scenarios?.filter((scenario) => isRecord(scenario) && scenario.status === "fail").length ?? 0;
const skippedScenarioCount =
scenarios?.filter(
(scenario) =>
@@ -407,14 +406,19 @@ function evaluateQaSuiteSummary(payload: unknown): QaConfidenceLaneEvaluation {
scenario.status !== "skip" &&
scenario.status !== "skipped"),
).length ?? 0;
- const hasScenarioRows = scenarios !== undefined && scenarios.length > 0;
+ const hasExecutedScenarios =
+ (failedCount ?? 0) > 0 ||
+ scenarios?.some(
+ (scenario) =>
+ isRecord(scenario) && (scenario.status === "pass" || scenario.status === "fail"),
+ ) === true ||
+ ((scenarios?.length ?? 0) === 0 && (passedCount ?? 0) > 0);
const gatewayLogSentinels = collectGatewayLogSentinels(payload);
if (gatewayLogSentinels.length > 0) {
const allEnvironmentBlocked = gatewayLogSentinels.every(
(finding) => finding.verdict === "environment-blocked",
);
- const suiteHasFailures =
- (failedCount !== undefined && failedCount > 0) || (failedScenarios?.length ?? 0) > 0;
+ const suiteHasFailures = (failedCount ?? 0) > 0 || failedScenarioCount > 0;
if (allEnvironmentBlocked && suiteHasFailures) {
return {
passed: false,
@@ -436,31 +440,42 @@ function evaluateQaSuiteSummary(payload: unknown): QaConfidenceLaneEvaluation {
details: `gateway log sentinel(s): ${formatGatewayLogSentinelSummary(gatewayLogSentinels)}`,
};
}
+ if (
+ failedCount !== undefined &&
+ scenarios !== undefined &&
+ Math.floor(failedCount) !== failedScenarioCount
+ ) {
+ return {
+ passed: false,
+ status: "unknown",
+ details: `qa-suite-summary count/scenario mismatch: counts.failed=${Math.max(
+ 0,
+ Math.floor(failedCount),
+ )}, failed scenarios=${failedScenarioCount}`,
+ };
+ }
+ if (unknownBlockingScenarioCount > 0) {
+ return {
+ passed: false,
+ status: "unknown",
+ details: `qa-suite-summary has ${unknownBlockingScenarioCount} scenario row(s) with unsupported non-pass status`,
+ };
+ }
+ if (failedCount === undefined && scenarios === undefined) {
+ return {
+ passed: false,
+ status: "unknown",
+ details: "qa-suite-summary missing counts.failed and scenarios[]",
+ };
+ }
+ if (!hasExecutedScenarios) {
+ return {
+ passed: false,
+ status: "unknown",
+ details: "qa-suite-summary has no executed scenarios",
+ };
+ }
if (failedCount !== undefined) {
- if (failedCount === 0 && !(totalCount !== undefined && totalCount > 0) && !hasScenarioRows) {
- return {
- passed: false,
- status: "unknown",
- details: "qa-suite-summary has no executed scenarios",
- };
- }
- if (failedScenarios !== undefined && Math.floor(failedCount) !== failedScenarios.length) {
- return {
- passed: false,
- status: "unknown",
- details: `qa-suite-summary count/scenario mismatch: counts.failed=${Math.max(
- 0,
- Math.floor(failedCount),
- )}, failed scenarios=${failedScenarios.length}`,
- };
- }
- if (unknownBlockingScenarioCount > 0) {
- return {
- passed: false,
- status: "unknown",
- details: `qa-suite-summary has ${unknownBlockingScenarioCount} scenario row(s) with unsupported non-pass status`,
- };
- }
const inferredSkippedCount =
totalCount === undefined || passedCount === undefined
? undefined
@@ -483,41 +498,11 @@ function evaluateQaSuiteSummary(payload: unknown): QaConfidenceLaneEvaluation {
...(skippedCount === 0 ? {} : { skippedCount: Math.max(0, Math.floor(skippedCount)) }),
};
}
- if (!Array.isArray(payload.scenarios)) {
- return {
- passed: false,
- status: "unknown",
- details: "qa-suite-summary missing counts.failed and scenarios[]",
- };
- }
- if (payload.scenarios.length === 0) {
- return {
- passed: false,
- status: "unknown",
- details: "qa-suite-summary has no executed scenarios",
- };
- }
- const fallbackFailedScenarios = payload.scenarios.filter(
- (scenario) => isRecord(scenario) && scenario.status === "fail",
- );
- const fallbackUnknownBlockingScenarios = payload.scenarios.filter(
- (scenario) =>
- !isRecord(scenario) ||
- (scenario.status !== "pass" &&
- scenario.status !== "fail" &&
- scenario.status !== "skip" &&
- scenario.status !== "skipped"),
- );
- if (fallbackUnknownBlockingScenarios.length > 0) {
- return {
- passed: false,
- status: "unknown",
- details: `qa-suite-summary has ${fallbackUnknownBlockingScenarios.length} scenario row(s) with unsupported non-pass status`,
- };
- }
+ const skippedCount = Math.max(explicitSkippedCount ?? 0, skippedScenarioCount);
return {
- passed: fallbackFailedScenarios.length === 0,
- details: `qa-suite-summary failed scenarios=${fallbackFailedScenarios.length}`,
+ passed: failedScenarioCount === 0,
+ details: `qa-suite-summary failed scenarios=${failedScenarioCount}`,
+ ...(skippedCount === 0 ? {} : { skippedCount }),
};
}
diff --git a/extensions/qa-lab/src/profile-selection.test.ts b/extensions/qa-lab/src/profile-selection.test.ts
index 7d45e70842fc..b1446cc0634a 100644
--- a/extensions/qa-lab/src/profile-selection.test.ts
+++ b/extensions/qa-lab/src/profile-selection.test.ts
@@ -52,6 +52,28 @@ describe("taxonomy profile scenario selection", () => {
).not.toContainEqual(expect.objectContaining({ sourcePath: unrelatedRefs[0] }));
});
+ it("selects the packaged first-run journey for release container setup", () => {
+ const catalog = readQaScenarioPack().scenarios;
+ const report = readQaScorecardTaxonomyReport(catalog);
+ const onboarding = catalog.find(
+ (scenario) => scenario.id === "docker-npm-onboard-channel-agent",
+ );
+ const systemAgent = catalog.find((scenario) => scenario.id === "docker-system-agent-first-run");
+
+ expect(onboarding?.coverage?.primary).toContain("containers.first-run-onboarding");
+ expect(systemAgent?.coverage?.primary).not.toContain("containers.first-run-onboarding");
+ expect(systemAgent?.coverage?.secondary).toContain("containers.first-run-onboarding");
+ expect(report.validationIssues).not.toContainEqual(
+ expect.objectContaining({
+ code: "coverage-id-missing-primary-inventory",
+ ref: "containers.first-run-onboarding",
+ }),
+ );
+ expect(report.profiles.find((profile) => profile.id === "release")?.scenarioRefs).toContain(
+ onboarding?.sourcePath,
+ );
+ });
+
it("derives channel defaults from catalog metadata and lane constraints", () => {
const liveTelegram = resolveLiveTransportQaScenarioIds({
channelId: "telegram",
diff --git a/qa/scenarios/runtime/docker-npm-onboard-channel-agent.yaml b/qa/scenarios/runtime/docker-npm-onboard-channel-agent.yaml
index 1f8457591194..33bdc1892afb 100644
--- a/qa/scenarios/runtime/docker-npm-onboard-channel-agent.yaml
+++ b/qa/scenarios/runtime/docker-npm-onboard-channel-agent.yaml
@@ -7,7 +7,6 @@ scenario:
coverage:
primary:
- containers.docker-backed-agent-sandbox-support
- secondary:
- containers.first-run-onboarding
objective: Verify a package-installed Docker runner can complete non-interactive onboarding, configure a channel, start Gateway-backed agent behavior, and complete a mocked model turn.
successCriteria:
diff --git a/scripts/control-ui-mock-cron.ts b/scripts/control-ui-mock-cron.ts
index 77edce9864e9..2017a0a92caa 100644
--- a/scripts/control-ui-mock-cron.ts
+++ b/scripts/control-ui-mock-cron.ts
@@ -182,6 +182,7 @@ export function buildCronMocks(baseTime: number) {
}));
const status: CronStatus = {
enabled: true,
+ triggersEnabled: true,
jobs: jobs.length,
nextWakeAtMs: overdueJob.state?.nextRunAtMs,
};
diff --git a/scripts/e2e/telegram-mantis-lane.ts b/scripts/e2e/telegram-mantis-lane.ts
index 8984affa344b..f8f8391f6c64 100644
--- a/scripts/e2e/telegram-mantis-lane.ts
+++ b/scripts/e2e/telegram-mantis-lane.ts
@@ -507,7 +507,7 @@ function publishTerminalLaneFacts(params: {
facts: unknown;
lane: Lane;
roots: Roots;
- status: "fail" | "pass";
+ status: "blocked" | "fail" | "pass";
sutAttestation?: SutAttestation;
}): void {
const privatePublished = path.join(params.roots.sessionRoot, "published", params.lane);
@@ -1309,7 +1309,7 @@ async function finalize(
facts,
lane: state.lane,
roots,
- status: status === "complete" ? "pass" : "fail",
+ status: status === "complete" ? "pass" : status === "blocked" ? "blocked" : "fail",
sutAttestation: state.sut.sutAttestation,
});
fs.rmSync(activeFile(roots.sessionRoot, state.lane), { force: true });
diff --git a/scripts/fixtures/packed-plugin-sdk-type-smoke.ts b/scripts/fixtures/packed-plugin-sdk-type-smoke.ts
index 1175a27fe245..f7cb6a4f678e 100644
--- a/scripts/fixtures/packed-plugin-sdk-type-smoke.ts
+++ b/scripts/fixtures/packed-plugin-sdk-type-smoke.ts
@@ -1,10 +1,4 @@
// Packed Plugin Sdk Type Smoke script supports OpenClaw repository automation.
-import type {
- MemoryReadResult,
- MemorySearchManager,
-} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
-import type { MemoryPluginRuntime } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
-
type PublicPluginSdkModules = [
typeof import("openclaw/plugin-sdk/core"),
typeof import("openclaw/plugin-sdk/channel-entry-contract"),
@@ -14,31 +8,5 @@ type PublicPluginSdkModules = [
];
const resolvedModules = null as unknown as PublicPluginSdkModules;
-declare const canonicalManagerRest: Omit;
-declare const canonicalReadResult: MemoryReadResult;
-
-const legacyManager = {
- ...canonicalManagerRest,
- async readFile({ relPath }: { relPath: string }) {
- return { text: "", path: relPath };
- },
-};
-const legacyRuntime = {
- async getMemorySearchManager() {
- return { manager: legacyManager };
- },
- resolveMemoryBackendConfig() {
- return { backend: "builtin" as const };
- },
-} satisfies MemoryPluginRuntime;
-type BareLegacyReadResult = { text: ""; path: string };
-const canonicalRejectsBareLegacy: BareLegacyReadResult extends MemoryReadResult ? false : true =
- true;
void resolvedModules;
-void legacyRuntime;
-void canonicalReadResult.from;
-void canonicalReadResult.lines;
-void canonicalReadResult.truncated;
-void canonicalReadResult.nextFrom;
-void canonicalRejectsBareLegacy;
diff --git a/scripts/lib/ci-changed-node-test-plan.mts b/scripts/lib/ci-changed-node-test-plan.mts
index 22ab1b39008d..486cbc333d82 100644
--- a/scripts/lib/ci-changed-node-test-plan.mts
+++ b/scripts/lib/ci-changed-node-test-plan.mts
@@ -45,6 +45,21 @@ const CHANGED_NODE_TEST_TARGETS_PER_JOB = 12;
// integration tests past the global timeout.
const SERIAL_CHANGED_TARGET_RE = /^extensions\/memory-core\//u;
const BOUNDARY_NODE_TEST_CONFIG = "test/vitest/vitest.boundary.config.ts";
+const DOCKER_SEED_LANE_ORDER = [
+ "mcp-channels",
+ "cron-mcp-cleanup",
+ "mcp-code-mode-gateway",
+] as const;
+type DockerSeedLane = (typeof DOCKER_SEED_LANE_ORDER)[number];
+const DOCKER_SEED_LANES_BY_PATH: Readonly> = {
+ ".github/workflows/ci.yml": DOCKER_SEED_LANE_ORDER,
+ "scripts/e2e/cron-mcp-cleanup-seed.ts": ["cron-mcp-cleanup"],
+ "scripts/e2e/docker-openai-seed.ts": DOCKER_SEED_LANE_ORDER,
+ "scripts/e2e/lib/mcp-code-mode-probe-server.ts": ["mcp-code-mode-gateway"],
+ "scripts/e2e/mcp-channels-seed.ts": ["mcp-channels"],
+ "scripts/e2e/mcp-code-mode-gateway-seed.ts": ["mcp-code-mode-gateway"],
+ "scripts/lib/ci-changed-node-test-plan.mts": DOCKER_SEED_LANE_ORDER,
+};
const publicPluginSdkEntrySources = Object.values(
buildPluginSdkEntrySources(publicPluginSdkEntrypoints),
);
@@ -61,6 +76,17 @@ const splitNodeTestConfigs = new Set(
fullNodeTestShards.filter((shard) => shard.includePatterns).flatMap((shard) => shard.configs),
);
+export function resolveChangedDockerSeedLanes(changedPaths: string[]) {
+ const selected = new Set();
+ for (const changedPath of changedPaths) {
+ const normalizedPath = changedPath.replaceAll("\\", "/");
+ for (const lane of DOCKER_SEED_LANES_BY_PATH[normalizedPath] ?? []) {
+ selected.add(lane);
+ }
+ }
+ return DOCKER_SEED_LANE_ORDER.filter((lane) => selected.has(lane));
+}
+
function isTestOnlyPath(changedPath: string) {
return (
isTestFileTarget(changedPath) ||
diff --git a/scripts/lib/npm-pack-budget.mts b/scripts/lib/npm-pack-budget.mts
index fbd7882547c4..b1d5e409a5e1 100644
--- a/scripts/lib/npm-pack-budget.mts
+++ b/scripts/lib/npm-pack-budget.mts
@@ -5,8 +5,9 @@ import { resolveNpmJsonEntries } from "./npm-json-output.mts";
// startup/doctor OOM reports. 2026.4.12 intentionally stages Matrix runtime
// dependencies, including crypto wasm, so packaged installs do not miss Docker
// and gateway runtime dependencies. Keep the budget below the 2026.3.12 bloat
-// level while allowing that mirrored runtime surface.
-const NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES = 202 * 1024 * 1024;
+// level while allowing that mirrored runtime surface and the installed agent's
+// bundled user documentation.
+const NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES = 204 * 1024 * 1024;
function formatMiB(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
diff --git a/scripts/mantis/build-telegram-desktop-proof-evidence.mts b/scripts/mantis/build-telegram-desktop-proof-evidence.mts
index cc42e48ff013..1dd1544745cc 100644
--- a/scripts/mantis/build-telegram-desktop-proof-evidence.mts
+++ b/scripts/mantis/build-telegram-desktop-proof-evidence.mts
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
type CliArgs = Record;
type LaneName = "baseline" | "candidate";
+type LaneStatus = "blocked" | "fail" | "pass";
type SessionSummary = {
artifacts?: Partial<
Record<
@@ -44,6 +45,7 @@ type TelegramDesktopProofManifest = {
comparison: {
baseline: { expected: string; status: string; ref?: string; sha?: string };
candidate: { expected: string; status: string; fixed: boolean; ref?: string; sha?: string };
+ outcome: LaneStatus;
pass: boolean;
};
artifacts: EvidenceArtifact[];
@@ -195,8 +197,8 @@ function copyLaneArtifacts({
});
}
-function laneStatus(lane: LoadedLane) {
- return lane.status === "pass" ? "pass" : "fail";
+function laneStatus(lane: LoadedLane): LaneStatus {
+ return lane.status === "pass" || lane.status === "blocked" ? lane.status : "fail";
}
function requireLaneAttestation(lane: LoadedLane, expectedLane: LaneName, expectedSha: string) {
@@ -216,7 +218,7 @@ function requireLaneAttestation(lane: LoadedLane, expectedLane: LaneName, expect
throw new Error(`SUT attestation mismatch for ${expectedLane}.`);
}
-function laneArtifactEntries(statuses: Record): EvidenceArtifact[] {
+function laneArtifactEntries(statuses: Record): EvidenceArtifact[] {
return LANES.flatMap(({ altPrefix, label, lane }) => [
{
alt: `${altPrefix} native Telegram Desktop proof GIF`,
@@ -287,7 +289,12 @@ function buildTelegramDesktopProofManifest({
}): TelegramDesktopProofManifest {
const baselineStatus = laneStatus(baseline);
const candidateStatus = laneStatus(candidate);
- const pass = baselineStatus === "pass" && candidateStatus === "pass";
+ const outcome =
+ baselineStatus === "fail" || candidateStatus === "fail"
+ ? "fail"
+ : baselineStatus === "blocked" || candidateStatus === "blocked"
+ ? "blocked"
+ : "pass";
return {
schemaVersion: 1,
id: "telegram-desktop-proof",
@@ -309,7 +316,8 @@ function buildTelegramDesktopProofManifest({
status: candidateStatus,
fixed: candidateStatus === "pass",
},
- pass,
+ outcome,
+ pass: outcome === "pass",
},
artifacts: laneArtifactEntries({ baseline: baselineStatus, candidate: candidateStatus }),
};
diff --git a/scripts/mantis/publish-pr-evidence.mjs b/scripts/mantis/publish-pr-evidence.mjs
index 5553707d0a5e..7fa73f9c1536 100644
--- a/scripts/mantis/publish-pr-evidence.mjs
+++ b/scripts/mantis/publish-pr-evidence.mjs
@@ -27,7 +27,7 @@ import { readBoundedResponseText } from "../lib/bounded-response.mjs";
/**
* @typedef {{
* artifacts: EvidenceArtifact[],
- * comparison: { baseline?: EvidenceLane, candidate: EvidenceLane, pass?: boolean },
+ * comparison: { baseline?: EvidenceLane, candidate: EvidenceLane, outcome?: "blocked" | "fail" | "pass", pass?: boolean },
* id: string,
* manifestDir: string,
* scenario: string,
@@ -385,6 +385,10 @@ function publicSummary(manifest) {
return manifest.summary ?? "Mantis captured QA evidence for this scenario.";
}
function overallStatus(manifest) {
+ const outcome = manifest.comparison?.outcome;
+ if (outcome === "blocked" || outcome === "fail" || outcome === "pass") {
+ return outcome;
+ }
const pass = manifest.comparison?.pass;
return typeof pass === "boolean" ? String(pass) : "";
}
@@ -396,6 +400,9 @@ export function shouldPublishPrComment(manifest, { requestSource } = {}) {
if (!isTelegramDesktopProof(manifest) || hasVisibleProofArtifacts(manifest)) {
return true;
}
+ if (manifest.comparison?.outcome === "blocked") {
+ return true;
+ }
if (requestSource === "pull_request_target") {
return false;
}
diff --git a/scripts/test-projects.test-support.mts b/scripts/test-projects.test-support.mts
index 8c56d7b4e3d0..0e119b4d5d80 100644
--- a/scripts/test-projects.test-support.mts
+++ b/scripts/test-projects.test-support.mts
@@ -2113,10 +2113,7 @@ const EXACT_TOOLING_TARGETS = new Map([
[".github/actions/setup-pnpm-store-cache/action.yml", [packageAcceptance, workflowGuards]],
[".github/actions/setup-pnpm-store-cache/ensure-node.sh", ["setup-pnpm-store-cache-ensure-node"]],
["test/e2e/qa-lab/runtime/mcp-channels-docker-client.ts", [dockerE2e, pluginPrerelease]],
- [
- "scripts/e2e/lib/mcp-code-mode-probe-server.ts",
- ["docker-e2e-seeds", "mcp-code-mode-gateway-client"],
- ],
+ ["scripts/e2e/lib/mcp-code-mode-probe-server.ts", ["mcp-code-mode-gateway-client"]],
["scripts/e2e/cron-cli-docker.sh", [dockerBuild, "docker-e2e-observability"]],
["scripts/ios-release-upload.sh", ["ios-release-wrapper-args", "ios-release-fastlane-gates"]],
["scripts/release-verify-beta.ts", ["release-wrapper-scripts"]],
@@ -2782,10 +2779,6 @@ const SEMANTIC_TOOLING_TARGET_PATTERNS: Array<[RegExp, string[]]> = [
),
["mcp-code-mode-gateway-client", "session-log-mentions"],
],
- [
- /^(?:scripts\/e2e\/(?:mcp-channels|mcp-code-mode-gateway|cron-mcp-cleanup)-seed\.ts)$/u,
- ["docker-e2e-seeds"],
- ],
[
/^scripts\/e2e\/(?:mcp-channels|cron-cli|cron-mcp-cleanup)-docker\.sh$/u,
["docker-e2e-observability"],
@@ -2800,10 +2793,7 @@ const SEMANTIC_TOOLING_TARGET_PATTERNS: Array<[RegExp, string[]]> = [
"src/cron/active-jobs-manual-run.test.ts",
],
],
- [
- /^scripts\/e2e\/cron-mcp-cleanup-docker\.sh$/u,
- ["cron-mcp-cleanup-docker-client", "docker-e2e-seeds"],
- ],
+ [/^scripts\/e2e\/cron-mcp-cleanup-docker\.sh$/u, ["cron-mcp-cleanup-docker-client"]],
[
/^test\/e2e\/qa-lab\/runtime\/mcp-channels\.fixture\.ts$/u,
["test/e2e/qa-lab/runtime/mcp-gateway-transport.e2e.test.ts", "cron-mcp-cleanup-docker-client"],
diff --git a/src/agents/harness/context-engine-turn-attempt.test.ts b/src/agents/harness/context-engine-turn-attempt.test.ts
index 6e6c0fc6738c..99bf70f890ea 100644
--- a/src/agents/harness/context-engine-turn-attempt.test.ts
+++ b/src/agents/harness/context-engine-turn-attempt.test.ts
@@ -330,14 +330,16 @@ describe("accepted context-engine turn finalization", () => {
),
).toMatchObject({ state: "blocked", failure: "stale" });
+ const nextAdmission = {
+ ...admission,
+ logicalTurnId: "logical-turn-next",
+ };
await drainPendingContextEngineTurnsBeforeRun({
- admission,
+ admission: nextAdmission,
lease,
warn,
});
- expect(lease.degradeBeforeStart).toHaveBeenCalledWith(
- "pending durable turn advancement could not be completed before the next turn",
- );
+ expect(lease.degradeBeforeStart).not.toHaveBeenCalled();
const sibling = await appendTranscriptMessage(target, {
message: { role: "assistant", content: "sibling" },
diff --git a/src/agents/harness/context-engine-turn-outbox.test.ts b/src/agents/harness/context-engine-turn-outbox.test.ts
index 670b3e4e5ec8..a6fbd7fe5c21 100644
--- a/src/agents/harness/context-engine-turn-outbox.test.ts
+++ b/src/agents/harness/context-engine-turn-outbox.test.ts
@@ -137,6 +137,50 @@ describe("context-engine turn outbox", () => {
).toBeUndefined();
});
+ it("keeps a row pending when its persisted payload has no state", async () => {
+ const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-context-outbox-state-"));
+ tempDirs.push(stateDir);
+ const database = openOpenClawAgentDatabase({
+ agentId: "main",
+ env: { OPENCLAW_STATE_DIR: stateDir },
+ });
+ const payload = createPayload({
+ advancementKey: "session-a:missing-state",
+ databasePath: database.path,
+ sequence: 1,
+ sessionId: "session-a",
+ });
+ enqueueContextEngineTurnCommit({ database, engineId: "test", payload });
+ database.db
+ .prepare(
+ "UPDATE context_engine_turn_outbox SET payload_json = '{}' WHERE advancement_key = ?",
+ )
+ .run(payload.boundary.admission.logicalTurnId);
+ const commitTurn = vi.fn(async () => ({ status: "committed" as const }));
+ const engine = {
+ info: { id: "test", name: "Test" },
+ ingest: async () => ({ ingested: true }),
+ assemble: async ({ messages }) => ({ messages, estimatedTokens: 0 }),
+ compact: async () => ({ ok: true, compacted: false }),
+ commitTurn,
+ } satisfies ContextEngine;
+
+ const result = await drainContextEngineTurnOutbox({
+ database,
+ engine,
+ engineId: "test",
+ warn: vi.fn(),
+ });
+
+ expect(result.pending).toBe(true);
+ expect(commitTurn).not.toHaveBeenCalled();
+ expect(
+ database.db
+ .prepare("SELECT 1 FROM context_engine_turn_outbox WHERE advancement_key = ?")
+ .get(payload.boundary.admission.logicalTurnId),
+ ).toBeDefined();
+ });
+
it("drains prior work before fresh-turn assembly and records dispatch admission", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-context-outbox-recovery-"));
tempDirs.push(stateDir);
@@ -372,7 +416,7 @@ describe("context-engine turn outbox", () => {
});
});
- it("retains unrecoverable accepted recovery as a blocking marker", async () => {
+ it("retains unrecoverable accepted recovery as a terminal marker without blocking later turns", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-context-outbox-blocked-"));
tempDirs.push(stateDir);
const database = openOpenClawAgentDatabase({
@@ -417,6 +461,17 @@ describe("context-engine turn outbox", () => {
expect.stringContaining("blocked unrecoverable turn advancement"),
);
+ enqueueContextEngineTurnCommit({
+ database,
+ engineId: "test",
+ payload: createPayload({
+ advancementKey: "session-a:later-ready",
+ databasePath: database.path,
+ sequence: 3,
+ sessionId: "session-a",
+ }),
+ });
+
const engine = {
info: {
id: "test",
@@ -445,22 +500,43 @@ describe("context-engine turn outbox", () => {
deferDisposalUntil: vi.fn(),
dispose: vi.fn(async () => undefined),
} satisfies ContextEngineLogicalTurnLease;
+ const currentAdmission = {
+ ...payload.boundary.admission,
+ logicalTurnId: "session-a:current",
+ };
await drainPendingContextEngineTurnsBeforeRun({
- admission: payload.boundary.admission,
+ admission: currentAdmission,
lease,
warn,
});
- expect(engine.commitTurn).not.toHaveBeenCalled();
- expect(degradeBeforeStart).toHaveBeenCalledWith(
- "pending durable turn advancement could not be completed before the next turn",
+ expect(engine.commitTurn).toHaveBeenCalledOnce();
+ expect(engine.commitTurn).toHaveBeenCalledWith(
+ expect.objectContaining({ advancementKey: "session-a:later-ready" }),
);
+ expect(degradeBeforeStart).not.toHaveBeenCalled();
expect(
database.db
.prepare("SELECT 1 FROM context_engine_turn_outbox WHERE advancement_key = ?")
.get(payload.boundary.admission.logicalTurnId),
).toBeDefined();
+ expect(
+ database.db
+ .prepare("SELECT 1 FROM context_engine_turn_outbox WHERE advancement_key = ?")
+ .get("session-a:later-ready"),
+ ).toBeUndefined();
+ expect(
+ JSON.parse(
+ (
+ database.db
+ .prepare(
+ "SELECT payload_json FROM context_engine_turn_outbox WHERE advancement_key = ?",
+ )
+ .get(currentAdmission.logicalTurnId) as { payload_json: string }
+ ).payload_json,
+ ),
+ ).toMatchObject({ state: "admitted" });
});
it("does not let later same-session turns overtake a failed commit", async () => {
diff --git a/src/agents/harness/context-engine-turn-outbox.ts b/src/agents/harness/context-engine-turn-outbox.ts
index 904e357a6396..b0a840c4c112 100644
--- a/src/agents/harness/context-engine-turn-outbox.ts
+++ b/src/agents/harness/context-engine-turn-outbox.ts
@@ -75,6 +75,12 @@ function oldestOutboxEnqueueSequence() {
return /* kysely-allow-raw: Aggregate the closed implicit-rowid expression used for enqueue order. */ sql`MIN(context_engine_turn_outbox.rowid)`;
}
+function outboxPayloadRequiresAdvancement() {
+ // Blocked rows are terminal audit evidence, not retryable work. Keep them
+ // inspectable without letting them hold later same-session turns behind them.
+ return /* kysely-allow-raw: Payload state is owned by the closed outbox union above. */ sql`json_extract(context_engine_turn_outbox.payload_json, '$.state') IS NOT 'blocked'`;
+}
+
export function isRetryableContextEngineTurnReadFailure(
kind: ContextEngineTurnReadFailureKind,
): kind is "projection-unavailable" {
@@ -356,7 +362,8 @@ export async function drainContextEngineTurnOutbox(params: {
// Use it instead of wall-clock timestamps, which can collide.
.select(oldestOutboxEnqueueSequence().as("oldest_enqueue_sequence"))
.where("engine_id", "=", params.engineId)
- .where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null);
+ .where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null)
+ .where(outboxPayloadRequiresAdvancement());
if (params.sessionId) {
pendingSessionsQuery = pendingSessionsQuery.where("session_id", "=", params.sessionId);
}
@@ -382,6 +389,7 @@ export async function drainContextEngineTurnOutbox(params: {
.where("engine_id", "=", params.engineId)
.where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null)
.where("session_id", "=", sessionId)
+ .where(outboxPayloadRequiresAdvancement())
.orderBy(outboxEnqueueSequence(), "asc")
.limit(1),
);
@@ -409,7 +417,8 @@ function hasPendingContextEngineTurn(
.selectFrom("context_engine_turn_outbox")
.select("advancement_key")
.where("engine_id", "=", params.engineId)
- .where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null);
+ .where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null)
+ .where(outboxPayloadRequiresAdvancement());
if (params.sessionId) {
query = query.where("session_id", "=", params.sessionId);
}
diff --git a/src/agents/model-suppression.test.ts b/src/agents/model-suppression.test.ts
index 0b34194d3606..fbf7924c6363 100644
--- a/src/agents/model-suppression.test.ts
+++ b/src/agents/model-suppression.test.ts
@@ -18,7 +18,9 @@ import {
getCurrentPluginMetadataSnapshot,
setCurrentPluginMetadataSnapshot,
} from "../plugins/current-plugin-metadata-snapshot.js";
+import * as pluginControlPlaneContext from "../plugins/plugin-control-plane-context.js";
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
+import * as pluginMetadataSnapshot from "../plugins/plugin-metadata-snapshot.js";
import { withPluginRuntimeGenerationScope } from "../plugins/runtime/generation-scope.js";
import {
buildShouldSuppressBuiltInModelCore,
@@ -34,6 +36,7 @@ describe("model suppression", () => {
});
afterEach(() => {
+ vi.restoreAllMocks();
setCurrentPluginMetadataSnapshot(undefined);
if (originalBundledPluginsDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
@@ -131,7 +134,7 @@ describe("model suppression", () => {
expect(secondResolver).toHaveBeenCalledOnce();
});
- it("keeps concurrent scoped suppression resolvers isolated from process-current metadata", async () => {
+ it("reuses each concurrent generation's suppression resolver across A/B/A interleaving", async () => {
const config = {} satisfies OpenClawConfig;
const snapshotA = createPluginMetadataSnapshot({
config,
@@ -165,7 +168,14 @@ describe("model suppression", () => {
});
markAReady();
await holdA;
- return result;
+ return [
+ result,
+ shouldSuppressBuiltInModelCore({
+ provider: "openai",
+ id: "generation-model",
+ config,
+ }),
+ ];
},
);
await aReady;
@@ -181,8 +191,77 @@ describe("model suppression", () => {
);
releaseA();
- await expect(resultA).resolves.toBe(true);
+ await expect(resultA).resolves.toEqual([true, true]);
expect(resultB).toBe(false);
+ expect(mocks.buildManifestBuiltInModelSuppressionResolver).toHaveBeenCalledTimes(2);
+ });
+
+ it("keys generation resolvers by config identity and workspace", () => {
+ const configA = {} satisfies OpenClawConfig;
+ const configB = {} satisfies OpenClawConfig;
+ const snapshot = createPluginMetadataSnapshot({
+ config: configA,
+ manifestRegistry: { plugins: [], diagnostics: [] },
+ });
+ mocks.buildManifestBuiltInModelSuppressionResolver.mockReturnValue(() => undefined);
+
+ const check = (config: OpenClawConfig, workspaceDir: string) =>
+ withPluginRuntimeGenerationScope({ config, metadataSnapshot: snapshot }, () =>
+ shouldSuppressBuiltInModelCore({
+ provider: "openai",
+ id: "generation-model",
+ config,
+ workspaceDir,
+ }),
+ );
+
+ expect(check(configA, "/workspace/a")).toBe(false);
+ expect(check(configB, "/workspace/a")).toBe(false);
+ expect(check(configA, "/workspace/b")).toBe(false);
+ expect(check(configA, "/workspace/a")).toBe(false);
+ expect(mocks.buildManifestBuiltInModelSuppressionResolver).toHaveBeenCalledTimes(3);
+ });
+
+ it("does not recompute content fingerprints on a stable generation cache hit", () => {
+ const config = {} satisfies OpenClawConfig;
+ const snapshot = createPluginMetadataSnapshot({
+ config,
+ manifestRegistry: { plugins: [], diagnostics: [] },
+ });
+ mocks.buildManifestBuiltInModelSuppressionResolver.mockReturnValue(() => undefined);
+ const controlPlaneFingerprint = vi.spyOn(
+ pluginControlPlaneContext,
+ "resolvePluginControlPlaneFingerprint",
+ );
+ const envFingerprint = vi.spyOn(pluginMetadataSnapshot, "resolvePluginMetadataEnvFingerprint");
+
+ withPluginRuntimeGenerationScope({ config, metadataSnapshot: snapshot }, () => {
+ shouldSuppressBuiltInModelCore({ provider: "openai", id: "gpt-5.3", config });
+ controlPlaneFingerprint.mockClear();
+ envFingerprint.mockClear();
+
+ shouldSuppressBuiltInModelCore({ provider: "anthropic", id: "claude-4", config });
+
+ expect(controlPlaneFingerprint).not.toHaveBeenCalled();
+ expect(envFingerprint).not.toHaveBeenCalled();
+ });
+ });
+
+ it("rebuilds a generation resolver after plugin metadata lifecycle caches clear", () => {
+ const config = {} satisfies OpenClawConfig;
+ const snapshot = createPluginMetadataSnapshot({
+ config,
+ manifestRegistry: { plugins: [], diagnostics: [] },
+ });
+ mocks.buildManifestBuiltInModelSuppressionResolver.mockReturnValue(() => undefined);
+
+ withPluginRuntimeGenerationScope({ config, metadataSnapshot: snapshot }, () => {
+ shouldSuppressBuiltInModelCore({ provider: "openai", id: "gpt-5.3", config });
+ clearPluginMetadataLifecycleCaches();
+ shouldSuppressBuiltInModelCore({ provider: "anthropic", id: "claude-4", config });
+ });
+
+ expect(mocks.buildManifestBuiltInModelSuppressionResolver).toHaveBeenCalledTimes(2);
});
it("refreshes manifest suppression resolver when process env plugin metadata inputs change", () => {
diff --git a/src/agents/model-suppression.ts b/src/agents/model-suppression.ts
index 34ef2d846bc9..c234a74dfa04 100644
--- a/src/agents/model-suppression.ts
+++ b/src/agents/model-suppression.ts
@@ -6,29 +6,41 @@
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { normalizeLowercaseStringOrEmpty } from "../../packages/normalization-core/src/string-coerce.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
-import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
+import {
+ getCurrentPluginMetadataSnapshot,
+ isCurrentPluginMetadataSnapshotRuntimeGeneration,
+} from "../plugins/current-plugin-metadata-snapshot.js";
import { buildManifestBuiltInModelSuppressionResolver } from "../plugins/manifest-model-suppression.js";
import { resolvePluginControlPlaneFingerprint } from "../plugins/plugin-control-plane-context.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugins/plugin-metadata-lifecycle.js";
import { resolvePluginMetadataEnvFingerprint } from "../plugins/plugin-metadata-snapshot.js";
+import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
type ManifestSuppressionResolver = ReturnType;
-type CachedManifestSuppressionResolver = {
+type CachedStandaloneManifestSuppressionResolver = {
config: OpenClawConfig | undefined;
controlPlaneFingerprint: string;
cwd: string;
envFingerprint: string;
- metadataSnapshot: unknown;
+ metadataSnapshot: PluginMetadataSnapshot | undefined;
resolver: ManifestSuppressionResolver;
workspaceDir: string | undefined;
};
-let cachedManifestSuppressionResolver: CachedManifestSuppressionResolver | undefined;
+const configlessRuntimeGeneration = {};
+let runtimeGenerationResolvers = new WeakMap<
+ PluginMetadataSnapshot,
+ WeakMap