fix: keep 2026.9.9 upgrades on state schema 19 (#166959)

* fix: keep 2026.9.9 upgrades on state schema 19

* fix: preserve quarantine coverage during release rollback

* fix(cron): preserve scheduled authority in schema rollback

* fix(ci): restore Periphery-owned macOS index and propagate failures
This commit is contained in:
Dallin Romney authored and GitHub committed 2026-10-07 23:18:09 -07:00
1 parent b899f42513
commit bcfc88812a
78 files changed
+460 -4554

No files matched your search

@@ -189,6 +189,7 @@ jobs:
if [ ! -s "$output_dir/periphery.json" ]; then
cp "$output_dir/periphery.stdout.json" "$output_dir/periphery.json"
fi
exit "$periphery_status"
# Failed-job reruns retain successful producer artifacts from the original attempt.
# Keep artifact slots run-scoped; rerun producers overwrite their slot.
@@ -250,26 +251,16 @@ jobs:
./scripts/install-periphery.sh "$periphery_dir"
echo "$periphery_dir" >> "$GITHUB_PATH"
- name: Build macOS consumer index
- name: Scan shared kit
run: |
set -euo pipefail
output_dir="$RUNNER_TEMP/shared-periphery-macos"
mkdir -p "$output_dir"
cd apps/macos
swift package --scratch-path ../../.cache/periphery-swift clean
/usr/bin/time -p swift build --build-system native --scratch-path ../../.cache/periphery-swift --build-tests --enable-index-store \
2>&1 | tee "$output_dir/build.log"
- name: Scan shared kit
run: |
set -euo pipefail
output_dir="$RUNNER_TEMP/shared-periphery-macos"
cd apps/macos
set +e
periphery scan \
--config .periphery.yml \
--skip-build \
--index-store-path ../../.cache/periphery-swift/debug/index/store \
--clean-build \
--format json \
--report-include '../shared/OpenClawKit/Sources/**' \
--retain-files '../shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift' \
@@ -282,6 +273,7 @@ jobs:
if [ ! -s "$output_dir/periphery.json" ]; then
cp "$output_dir/periphery.stdout.json" "$output_dir/periphery.json"
fi
exit "$periphery_status"
- name: Upload macOS consumer report
if: always()
@@ -40,7 +40,7 @@ public enum OpenClawNativeStateSQLiteValueType: Equatable, Sendable {
/// One recursive connection lock serializes transactions and statement access.
public final class OpenClawNativeStateSQLite: @unchecked Sendable {
// Keep aligned with OPENCLAW_STATE_SCHEMA_VERSION. Native clients never upgrade this database.
private static let maximumSupportedSchemaVersion: Int64 = 20
private static let maximumSupportedSchemaVersion: Int64 = 19
private static let defaultBusyTimeoutMilliseconds: Int32 = 5000
private struct SchemaObject: Hashable {
-6
View File
@@ -29,13 +29,7 @@ const repositoryScriptEntries = [
// tsdown builds this private macOS app worker protocol entry by path.
"src/node-host/mac-worker-entry.ts!",
// Retained beta updaters import these stable private dist entries after package replacement.
"src/cli/update-cli/update-command-immutable.ts!",
"src/daemon/launchd-update-compat.ts!",
"src/infra/update-immutable-activation.ts!",
"src/infra/update-managed-service-handoff-current.ts!",
"src/infra/update-recovery-baseline-capture.ts!",
"src/plugins/plugin-lifecycle-lease.ts!",
"src/state/openclaw-state-lease-worker-operation.ts!",
// CI imports this selector from its trusted harness inside an inline Node script.
".github/actions/git-owner/test-prerequisites.mjs!",
// The frozen Node compatibility action invokes this exact-candidate repair by path.
+5
View File
@@ -347,6 +347,11 @@ The macOS Periphery configuration retains the native SwiftPM backend because
Periphery 3.8 reads its `.build/debug/index/store` layout. Native test
crashes emit noninteractive Swift backtraces, without register dumps.
The shared macOS scan lets Periphery own the clean build and index selection.
Both shared consumer jobs preserve their result and status artifacts, then exit
with the scanner's status. A scanner crash therefore fails its producer so a
failed-job rerun rebuilds that consumer instead of reusing its failed report.
Ordinary Markdown and MDX pages under `docs/`, plus root `README.md`, retain
their separate `check-docs` coverage beside precise pull-request Node tests.
Page deletions and renames preserve this targeting. Explicit Node owners for
@@ -99,6 +99,12 @@ The scheduler declares `anthropic-api-key` for this lane, and both full-chunk an
targeted-lane preflights require `ANTHROPIC_API_KEY` specifically; OAuth credentials
remain accepted for the other Anthropic lanes that support them.
First-hop compatibility covers published source versions no newer than the
candidate. A maintenance release does not require downgrade compatibility with a
newer beta on npm. The 2026.9.9 release retains shared state schema 19 from
2026.9.8; databases already migrated to schema 20 by the October beta remain
incompatible and are refused without mutation.
First-hop compatibility lanes share a 3,200-second inner container budget and a
3,500-second outer lane budget. Hosted 4-vCPU run `36506342273` spent about 1,558
seconds before its final candidate hop; another 560 seconds for that hop and about
+1 -1
View File
@@ -4,7 +4,7 @@
"openclaw": {
"updateAdmissionProtocol": 1,
"schemaVersions": {
"state": 20,
"state": 19,
"agent": 24
}
},
@@ -154,7 +154,6 @@ for version in "${SOURCE_VERSIONS[@]}"; do
echo "Running packaged updater first-hop compatibility Docker E2E (${version:-explicit source})..."
docker_e2e_run_with_harness \
-e OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 \
-e OPENCLAW_QA_ALLOW_UPDATE_FIRST_HOP=1 \
-e OPENCLAW_UPDATE_FIRST_HOP_ARTIFACT_DIR=/tmp/openclaw-update-first-hop-artifacts \
-e OPENCLAW_UPDATE_FIRST_HOP_EXPECTED_MISSING_CHUNK="$expected_missing_chunk" \
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1317,7 +1317,6 @@ src/state/openclaw-state-db-cache.ts
src/state/openclaw-state-db-content-version.ts
src/state/openclaw-state-db-cache.wal.ts
src/state/openclaw-state-db-contract.ts
src/state/openclaw-state-db-cron-delivery-migration.ts
src/state/openclaw-state-db-delivery-queue-backfill.ts
src/state/openclaw-state-db-doctor-schema.ts
src/state/openclaw-state-db-existing-schema.ts
+29 -1
View File
@@ -14,6 +14,9 @@ import {
} from "./lib/update-compat-chunks.mts";
const DEFAULT_OUTPUT = "scripts/lib/update-compat-inventory.json";
const TARGET_VERSION: string = JSON.parse(
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
).version;
const INTEGRITY = /^sha512-[A-Za-z0-9+/]{86}==$/;
function npmView(packageName: string, field: string): unknown {
@@ -37,6 +40,27 @@ function sortedReleases<T extends { version: string }>(releases: T[]): T[] {
});
}
// Post-swap bridges support upgrades into this candidate, not newer-release downgrades.
function isSupportedSource(version: string): boolean {
const comparison = compareReleaseVersions(version, TARGET_VERSION);
if (comparison === null) {
throw new Error(
`Invalid compatibility source or target version: ${version}, ${TARGET_VERSION}`,
);
}
return comparison <= 0;
}
function assertSupportedSources(inventory: UpdateCompatibilityInventory): void {
for (const { version } of inventory.releases) {
if (!isSupportedSource(version)) {
throw new Error(
`Update compatibility source ${version} is newer than target ${TARGET_VERSION}; downgrade compatibility is not supported.`,
);
}
}
}
function shellArgument(value: string): string {
return /^[A-Za-z0-9_./=+-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
}
@@ -53,7 +77,9 @@ function checkRegistryCoverage(inventory: UpdateCompatibilityInventory, output:
if (typeof version !== "string" || parseReleaseVersion(version)?.version !== version) {
throw new Error(`npm OpenClaw dist-tag ${tag} is missing or invalid`);
}
taggedVersions.push(version);
if (isSupportedSource(version)) {
taggedVersions.push(version);
}
}
const missing = [...new Set(taggedVersions)].filter(
(version) => !inventory.releases.some((release) => release.version === version),
@@ -116,6 +142,7 @@ function runUpdateCompatibilityInventory(args: string[] = process.argv.slice(2))
}),
),
});
assertSupportedSources(inventory);
const contents = `${JSON.stringify(inventory, null, 2)}\n`;
if (values.check) {
readUpdateCompatibilityInventory(output);
@@ -128,6 +155,7 @@ function runUpdateCompatibilityInventory(args: string[] = process.argv.slice(2))
}
} else if (values.check) {
inventory = readUpdateCompatibilityInventory(output);
assertSupportedSources(inventory);
checkRegistryCoverage(inventory, values.output);
} else {
throw new Error(
-1
View File
@@ -153,7 +153,6 @@ describe("gateway update action", () => {
sessionId: "synthetic-session",
sessionKey,
jobId: "synthetic-job",
deliveryAttemptFence: null,
admissionSource,
});
try {
@@ -58,12 +58,7 @@ export function createMessageToolTurnAuthority(params: {
return {
authorization,
config: admitScheduled ? admitScheduled() : params.getConfig(),
hasChannelTurnContext: Boolean(
authorization &&
!authorization.scheduled &&
!authorization.deliveryAttempt &&
!dashboardRead,
),
hasChannelTurnContext: Boolean(authorization && !authorization.scheduled && !dashboardRead),
gatewayTurnCapability: dashboardRead && !isRead ? undefined : token,
scheduledRead: isRead ? authorization?.scheduled : undefined,
assertDashboardReadCurrent: isRead ? dashboardRead : undefined,
@@ -345,7 +345,6 @@ it("retains scheduled invocation config through bound Gateway dispatch after pre
sessionId,
sessionKey,
jobId,
deliveryAttemptFence: { beforeAttempt: vi.fn(async () => {}), assertCurrent: vi.fn() },
toolsAllow: ["message"],
scheduledToolPolicy: policy,
executionIdentity: {
@@ -440,7 +440,6 @@ it.each<ScheduledLifetimeCase>([
sessionId: runId,
sessionKey,
jobId,
deliveryAttemptFence: { beforeAttempt: vi.fn(async () => {}), assertCurrent: vi.fn() },
toolsAllow: ["message"],
scheduledToolPolicy,
});
@@ -180,7 +180,6 @@ export async function reportChannelRoomJoin(
cfg: params.cfg,
deps: createDefaultDeps(),
job,
deliveryAttemptFence: null,
message,
sessionKey: params.route.sessionKey,
agentId: params.route.agentId,
-3
View File
@@ -64,8 +64,6 @@ export async function withCliCommandCleanup<T>(
// the files; another importer's module cache does not preserve this resolution.
const { runCliDisposerAfterPending } = await import("./runtime-cleanup.js");
const { closeOpenClawStateDatabaseAsync } = await import("../state/openclaw-state-db-cache.js");
const { closeDefaultRetainedNativeWorkerSource } =
await import("../infra/worker-native-lifecycle.js");
const pluginResources = new CliPluginInvocationResources();
const releaseSignals = installCliSignalExitHandlers();
pluginResources.adopt({ release: async () => releaseSignals() });
@@ -88,7 +86,6 @@ export async function withCliCommandCleanup<T>(
// Owned shutdown runs before this drain; expired disposers keep their recorded outcome.
await runCliDisposerAfterPending("shared-state", async () => {
await closeOpenClawStateDatabaseAsync();
await closeDefaultRetainedNativeWorkerSource();
});
}
}),
@@ -1,27 +0,0 @@
import fs from "node:fs/promises";
import {
resolvePackageActivationAnchor,
resolvePackageActivationControl,
} from "../../infra/package-update-activation-paths.js";
import { resolveUpdateRoot, type UpdateCommandOptions } from "./shared.js";
/**
* Stable does not own beta's immutable installation format. Refuse it before the
* mutable updater can touch the selected installation; ordinary installs keep
* using the stable update path.
*/
export async function tryRunImmutableUpdateCommand(opts: UpdateCommandOptions): Promise<boolean> {
const root = opts.sourceUpdate?.root ?? (await resolveUpdateRoot());
const control = resolvePackageActivationControl(resolvePackageActivationAnchor(root));
try {
await fs.lstat(control);
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
return false;
}
throw error;
}
throw new Error(
"This installation uses a newer immutable update format. Its current generation remains unchanged; update it with the version that adopted it.",
);
}
-1
View File
@@ -87,7 +87,6 @@ describe("cron message action authority", () => {
sessionId: "persistent-message-session",
sessionKey: "cron:long-message-read",
jobId,
deliveryAttemptFence: { beforeAttempt: vi.fn(async () => {}), assertCurrent: vi.fn() },
toolsAllow: ["message"],
scheduledToolPolicy: { version: 1, mode: "trusted" },
});
-5
View File
@@ -1,5 +0,0 @@
/** Host-held occurrence authority; never serialized into a tool or transport request. */
export type CronCompletionDeliveryFence = {
beforeAttempt: () => Promise<void>;
assertCurrent: () => void;
};
+1 -11
View File
@@ -14,7 +14,6 @@ import { resolveAgentOutboundIdentity } from "../infra/outbound/identity.js";
import { buildOutboundSessionContext } from "../infra/outbound/session-context.js";
import { CRON_DIRECT_DELIVERY_CONTEXT_KIND } from "../shared/transcript-only-openclaw-assistant.js";
import "./delivery-plan.js";
import type { CronCompletionDeliveryFence } from "./delivery-attempt-fence.js";
import {
appendAdmittedDirectCronDeliveryTranscriptMirror,
commitDirectCronOutboundRoute,
@@ -109,11 +108,7 @@ export async function sendCronAnnouncePayloadStrict(params: {
target: CronAnnounceTarget;
payload: ReplyPayload;
abortSignal: AbortSignal;
completion?: {
job: CronJob;
runStartedAt: number;
deliveryAttemptFence?: CronCompletionDeliveryFence | null;
};
completion?: { job: CronJob; runStartedAt: number };
onDeliveryAttempt?: (reachedRecipient: boolean) => void;
}): Promise<CronAnnounceDeliveryOutcome> {
const delivery = await resolveCronAnnounceDelivery(params);
@@ -140,10 +135,6 @@ export async function sendCronAnnouncePayloadStrict(params: {
// Resolution can settle after its caller's deadline; never start plugin
// delivery once the Gateway has released ownership of the timed-out work.
params.abortSignal.throwIfAborted();
const deliveryAttemptFence = params.completion?.deliveryAttemptFence;
await deliveryAttemptFence?.beforeAttempt();
params.abortSignal.throwIfAborted();
deliveryAttemptFence?.assertCurrent();
// Cron delivery is durable and non-best-effort for primary announces; partial
// channel failure must surface as a cron run failure.
@@ -161,7 +152,6 @@ export async function sendCronAnnouncePayloadStrict(params: {
bestEffort: false,
deps: createOutboundSendDeps(params.deps),
signal: params.abortSignal,
assertDirectAdapterHandoff: deliveryAttemptFence?.assertCurrent,
...(route ? { onPayload: (payload) => deliveredPayloads.push(payload) } : {}),
onDeliveryResult: () => {
if (!recipientReached) {
@@ -95,7 +95,6 @@ export async function runCronTurn(home: string, options: RunCronTurnOptions = {}
...makeJob(jobPayload),
delivery: options.delivery ?? { mode: "none" },
},
deliveryAttemptFence: null,
message:
options.message ?? (jobPayload.kind === "agentTurn" ? jobPayload.message : DEFAULT_MESSAGE),
sessionKey: options.sessionKey ?? DEFAULT_SESSION_KEY,
@@ -38,8 +38,7 @@ export async function commitCurrentSessionCronCompletion(
if (!sourceSessionKey) {
return { ok: false, reason: "current cron delivery is missing its source session binding" };
}
const sourceSessionGeneration = params.sourceSessionGeneration;
if (!sourceSessionGeneration) {
if (!params.sourceSessionGeneration) {
return { ok: false, reason: "current cron delivery is missing its source session generation" };
}
const transcriptPayloads = buildDirectCronTranscriptMirrorPayloads(params.deliveryPayloads);
@@ -53,11 +52,10 @@ export async function commitCurrentSessionCronCompletion(
let preparedContent: Record<string, unknown>[] | undefined;
let appended = false;
try {
await params.deliveryAttemptFence?.beforeAttempt();
const committed = await commitBackgroundResultToSession({
agentId: params.agentId,
sessionKey: sourceSessionKey,
expectedGeneration: sourceSessionGeneration,
expectedGeneration: params.sourceSessionGeneration,
text: completionText,
prepareDisplayContent: async () => {
const { assistantContent } = await buildAssistantReplyContent({
@@ -90,10 +88,6 @@ export async function commitCurrentSessionCronCompletion(
provenance: { kind: "cron", jobId: params.job.id, runId },
config: params.cfgWithAgentDefaults,
signal: params.abortSignal,
assertCurrent: () => {
params.abortSignal?.throwIfAborted();
params.deliveryAttemptFence?.assertCurrent();
},
onMessageCommitted: (result, acceptCompletion) => {
// Promote before publication; retries own the original committed blocks.
// Preserve committed media even when promotion or the later drain fails.
@@ -4,7 +4,6 @@ import type { CliDeps } from "../../cli/outbound-send-deps.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { TtsAutoMode } from "../../config/types.tts.js";
import type { SourceDeliveryOutcome } from "../../infra/outbound/source-delivery-plan.js";
import type { CronCompletionDeliveryFence } from "../delivery-attempt-fence.js";
import type { CronDeliveryPlan } from "../delivery-plan.js";
import type { CronJob, CronResolvedDeliveryState } from "../types.js";
import type { DeliveryTargetResolution } from "./delivery-target.js";
@@ -15,7 +14,6 @@ export type DispatchCronDeliveryParams = {
cfgWithAgentDefaults: OpenClawConfig;
deps: CliDeps;
job: CronJob;
deliveryAttemptFence?: CronCompletionDeliveryFence | null;
agentId: string;
agentSessionKey: string;
sourceSessionKey?: string;
@@ -318,9 +318,6 @@ export async function dispatchCronDelivery(
}
: undefined;
const runDelivery = async () => {
await params.deliveryAttemptFence?.beforeAttempt();
params.abortSignal?.throwIfAborted();
params.deliveryAttemptFence?.assertCurrent();
attemptedPayloadsForMirror.length = 0;
const send = await sendDurableMessageBatchCore({
cfg: params.cfgWithAgentDefaults,
@@ -338,7 +335,6 @@ export async function dispatchCronDelivery(
completionRetention: DIRECT_CRON_DELIVERY_COMPLETION_RETENTION,
deps: createOutboundSendDeps(params.deps),
signal: params.abortSignal,
assertDirectAdapterHandoff: params.deliveryAttemptFence?.assertCurrent,
onError,
onPayload: (payload) => {
attemptedPayloadsForMirror.push(payload);
+13 -45
View File
@@ -2,7 +2,6 @@ import {
type AdmittedRunContext,
createOperationalRunInstanceRef,
prepareAgentRunAdmission,
resolveAdmittedRunActiveAssertion,
} from "../../agents/admitted-run-context.js";
import { AgentHarnessPreflightError } from "../../agents/harness/errors.js";
import type { ScheduledToolPolicyContext } from "../../agents/scheduled-tool-policy.js";
@@ -23,7 +22,6 @@ import {
captureCronJobMessageActionAuthority,
captureCronJobMessageSourceAuthority,
} from "../active-jobs.js";
import type { CronCompletionDeliveryFence } from "../delivery-attempt-fence.js";
import type { CronRuntimeAuthority } from "../runtime-authority.js";
import type { CronExecutionIdentityAdmission } from "../service/state.js";
@@ -50,21 +48,19 @@ export function prepareCronPromptRunAdmission(params: {
agentId: string;
runId: string;
sessionId?: string;
ingressBoundary?: "cron.isolated-agent" | "cron.script";
resolveGatewayContext?: GatewayContextResolver;
sessionKey: string;
jobId: string;
deliveryAttemptFence?: CronCompletionDeliveryFence | null;
channelRequester?: CronAuthenticatedChannelRequester;
toolsAllow?: string[];
scheduledToolPolicy?: ScheduledToolPolicyContext;
executionIdentity?: CronExecutionIdentityAdmission;
ingressBoundary?: "cron.isolated-agent" | "cron.script";
resolveGatewayContext?: GatewayContextResolver;
}) {
const { runId, scheduledToolPolicy } = params;
const operationalRunInstance = createOperationalRunInstanceRef(runId);
const resolveGatewayContext =
params.resolveGatewayContext ?? getPluginRuntimeGatewayRequestScope()?.resolveGatewayContext;
let assertAdmitted: (() => void) | undefined;
const basePreparedRunAdmission = prepareAgentRunAdmission({
operationalRunInstance,
admissionSource: params.admissionSource,
@@ -79,10 +75,7 @@ export function prepareCronPromptRunAdmission(params: {
},
...(params.executionIdentity?.invoker ? { invoker: params.executionIdentity.invoker } : {}),
},
onAdmitted: (admitted) => {
bindGatewayContextResolver(admitted, resolveGatewayContext);
assertAdmitted = resolveAdmittedRunActiveAssertion(admitted);
},
onAdmitted: (admitted) => bindGatewayContextResolver(admitted, resolveGatewayContext),
});
const preparedRunAdmission = params.executionIdentity?.onPostAdmission
? withPostAdmissionExecutionOwnerBinding(
@@ -97,50 +90,25 @@ export function prepareCronPromptRunAdmission(params: {
const scheduledMessageSourceAuthority = scheduledMessageAuthority
? captureCronJobMessageSourceAuthority({ jobId: params.jobId, operationalRunInstance })
: undefined;
const deliveryAttemptFence = params.deliveryAttemptFence;
if (scheduledMessageAuthority && deliveryAttemptFence === null) {
preparedRunAdmission.close();
throw new Error("scheduled message authority requires its occurrence delivery fence");
}
// This opaque token remains unusable until this exact operational instance
// is admitted by the live occurrence. Both runners redeem the same host grant.
const messageActionTurnCapability =
deliveryAttemptFence || (scheduledMessageAuthority && scheduledToolPolicy)
scheduledMessageAuthority && scheduledToolPolicy
? mintMessageActionTurnCapability({
agentId: params.agentId,
runId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
requesterAccountId:
scheduledMessageAuthority && scheduledToolPolicy?.mode === "account"
? scheduledToolPolicy.ownerAccountId
: undefined,
...(deliveryAttemptFence
? {
deliveryAttempt: {
beforeAttempt: () => deliveryAttemptFence.beforeAttempt(),
assertCurrent: () => {
if (!assertAdmitted) {
throw new Error("cron message delivery requires its admitted run");
}
assertAdmitted();
deliveryAttemptFence.assertCurrent();
},
},
}
: {}),
...(scheduledMessageAuthority && scheduledToolPolicy
? {
scheduled: {
policy: scheduledToolPolicy,
assertCurrent: scheduledMessageAuthority,
...(scheduledMessageSourceAuthority
? { assertSourceCurrent: scheduledMessageSourceAuthority }
: {}),
...(params.channelRequester ? { channelRequester: params.channelRequester } : {}),
},
}
: {}),
scheduledToolPolicy.mode === "account" ? scheduledToolPolicy.ownerAccountId : undefined,
scheduled: {
policy: scheduledToolPolicy,
assertCurrent: scheduledMessageAuthority,
...(scheduledMessageSourceAuthority
? { assertSourceCurrent: scheduledMessageSourceAuthority }
: {}),
...(params.channelRequester ? { channelRequester: params.channelRequester } : {}),
},
expiresWithRun: true,
})
: undefined;
@@ -41,13 +41,7 @@ export type CronRunExecutionParams = Pick<
> &
Pick<
RunCronAgentTurnParams,
| "cfg"
| "job"
| "lane"
| "onLaneWait"
| "executionIdentity"
| "admissionSource"
| "deliveryAttemptFence"
"cfg" | "job" | "lane" | "onLaneWait" | "executionIdentity" | "admissionSource"
> & {
runId: string;
agentVerboseDefault: AgentDefaultsConfig["verboseDefault"];
+4 -3
View File
@@ -118,8 +118,10 @@ function resolveIsolatedCronPromptCacheKey(params: {
provider: params.provider,
model: params.model,
});
// Keep cache affinity on stable job identity; isolated cron rotates transcript/session ids.
return `openclaw-cron-${createHash("sha256").update(material).digest("hex").slice(0, 32)}`;
const digest = createHash("sha256").update(material).digest("hex").slice(0, 32);
// Isolated cron rotates transcript/session ids per run; keep cache affinity
// on stable job identity without sending raw local session labels upstream.
return `openclaw-cron-${digest}`;
}
/** Detects single-line cron prompts that look like shell commands or command invocations. */
@@ -249,7 +251,6 @@ function createCronPromptExecutor(
sessionId: params.cronSession.sessionEntry.sessionId,
sessionKey: params.runSessionKey,
jobId: params.job.id,
deliveryAttemptFence: params.deliveryAttemptFence,
channelRequester: resolveCronAuthenticatedChannelRequester(params.job),
toolsAllow: params.agentPayload?.toolsAllow,
scheduledToolPolicy,
-1
View File
@@ -352,7 +352,6 @@ export async function finalizeCronRun(params: {
cfgWithAgentDefaults: prepared.cfgWithAgentDefaults,
deps: prepared.input.deps,
job: prepared.input.job,
deliveryAttemptFence: prepared.input.deliveryAttemptFence,
agentId: prepared.agentId,
agentSessionKey: prepared.agentSessionKey,
sourceSessionKey: prepared.sourceSessionKey,
@@ -7,7 +7,6 @@ import type { CliDeps } from "../../cli/outbound-send-deps.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import type { SkillSnapshot } from "../../skills/types.js";
import type { CronCompletionDeliveryFence } from "../delivery-attempt-fence.js";
import type {
CronAgentExecutionPhaseUpdate,
CronAgentExecutionStarted,
@@ -23,7 +22,6 @@ export type RunCronAgentTurnParams = {
cfg: OpenClawConfig;
deps: CliDeps;
job: CronStoredJob;
deliveryAttemptFence?: CronCompletionDeliveryFence | null;
message: string;
abortSignal?: AbortSignal;
signal?: AbortSignal;
-1
View File
@@ -236,7 +236,6 @@ export async function runCronIsolatedAgentTurn(
runId,
cfg: params.cfg,
job: params.job,
deliveryAttemptFence: params.deliveryAttemptFence,
lane: params.lane,
agentVerboseDefault: prepared.context.agentCfg?.verboseDefault,
persistRunContinuationSession: prepared.context.runContinuationSession?.sync,
+3 -16
View File
@@ -242,22 +242,9 @@ describe("CronService restart catch-up", () => {
}),
],
async (cron, deps) => {
const recovered = cron.getJob("restart-job");
expect(recovered).toMatchObject({
enabled: false,
deleteAfterRun: true,
schedule: { kind: "at", at: "2025-12-13T16:00:00.000Z" },
state: {
lastRunAtMs: runningAtMs,
lastRunStatus: "error",
lastDeliveryStatus: "unknown",
},
});
expect(recovered?.state.runningAtMs).toBeUndefined();
expect(recovered?.state.nextRunAtMs).toBeUndefined();
expect(recovered?.state.startupCatchupAtMs).toBeUndefined();
expect(deps.enqueueSystemEvent).not.toHaveBeenCalled();
expect(deps.requestHeartbeat).not.toHaveBeenCalled();
expect(cron.getJob("restart-job")).toBeUndefined();
expect(deps.enqueueSystemEvent).toHaveBeenCalledOnce();
expect(deps.requestHeartbeat).toHaveBeenCalledOnce();
expect(deps.onEvent).toHaveBeenCalledWith(
expect.objectContaining({
jobId: "restart-job",
-105
View File
@@ -1,105 +0,0 @@
import { captureOpenClawStateWorkerContext } from "../../state/openclaw-state-worker-context.js";
import {
isCronActiveJobMarkerCurrent,
isCronSelfRemovalCurrent,
type CronActiveJobMarker,
} from "../active-jobs.js";
import { describeUnavailableCronAgent } from "../agent-availability.js";
import type { CronCompletionDeliveryFence } from "../delivery-attempt-fence.js";
import { CronRunReceiptRevisionError } from "../store/run-receipt-store.js";
import type { CronRunReceiptHandle } from "../store/run-receipt.types.js";
import type { CronJob } from "../types.js";
import { runCronRuntimeMutation } from "./runtime-mutation.js";
import type { CronServiceState } from "./state.js";
export function createCronCompletionDeliveryFence(params: {
state: CronServiceState;
job: CronJob;
handle: CronRunReceiptHandle;
activeJobMarker?: CronActiveJobMarker;
signal: AbortSignal;
}): CronCompletionDeliveryFence {
const { state, handle, activeJobMarker, signal } = params;
const context = captureOpenClawStateWorkerContext();
const generation = state.lifecycleGeneration;
const reservation = state.queuedRunReservationsByJobId.get(handle.jobId);
const defaultAgentId = () => state.deps.resolveDefaultAgentId?.() ?? state.deps.defaultAgentId;
const admittedDefaultAgentId = defaultAgentId();
let preparedAgentFacts: { deletionBlocked: boolean } | undefined;
const allowMissingJob = () =>
activeJobMarker?.jobId === handle.jobId && isCronSelfRemovalCurrent(activeJobMarker);
const assertCurrent = () => {
context.admission.assertCurrent();
signal.throwIfAborted();
if (
preparedAgentFacts &&
state.deps.isAgentAvailable?.(handle.agentId, undefined, preparedAgentFacts) === false
) {
throw new CronRunReceiptRevisionError(
handle.receiptId,
describeUnavailableCronAgent(handle.agentId),
"owner-unavailable",
);
}
if (
state.lifecycleGeneration !== generation ||
!reservation ||
state.queuedRunReservationsByJobId.get(handle.jobId) !== reservation ||
reservation.runReceipt.receiptId !== handle.receiptId ||
!isCronActiveJobMarkerCurrent(activeJobMarker) ||
activeJobMarker?.cancellation?.kind === "requested" ||
(activeJobMarker?.jobRemoved && !allowMissingJob()) ||
(!params.job.agentId?.trim() && defaultAgentId() !== admittedDefaultAgentId)
) {
throw new CronRunReceiptRevisionError(handle.receiptId, "cron delivery owner retired");
}
};
return {
assertCurrent,
async beforeAttempt() {
assertCurrent();
let committed = false;
try {
await runCronRuntimeMutation({
context,
type: "cron.markDeliveryStarted",
input: { storeKey: handle.storeKey, handle: { ...handle } },
assertCurrent,
prepare(facts) {
preparedAgentFacts = facts;
const missingJobAllowed = allowMissingJob();
const assertPreparedCurrent = () => {
assertCurrent();
if (
facts.deletionBlocked ||
state.deps.isAgentAvailable?.(handle.agentId, undefined, facts) === false
) {
throw new CronRunReceiptRevisionError(
handle.receiptId,
describeUnavailableCronAgent(handle.agentId),
"owner-unavailable",
);
}
if (allowMissingJob() !== missingJobAllowed) {
throw new CronRunReceiptRevisionError(handle.receiptId);
}
};
assertPreparedCurrent();
return {
value: { allowMissingJob: missingJobAllowed, defaultAgentId: admittedDefaultAgentId },
assertCurrent: assertPreparedCurrent,
};
},
publish() {
committed = true;
},
});
} catch (error) {
if (!committed) {
throw error;
}
}
assertCurrent();
},
};
}
-6
View File
@@ -10,7 +10,6 @@ import type { SessionEventWakeWaitOptions } from "../../infra/session-event-wake
import { LEGACY_IMPLICIT_AGENT_ID } from "../../routing/session-key.js";
import type { DeliveryContext } from "../../utils/delivery-context.types.js";
import type { CronAgentAvailability } from "../agent-availability.js";
import type { CronCompletionDeliveryFence } from "../delivery-attempt-fence.js";
import { toPublicCronJob } from "../public-job.js";
import type { CronRuntimeAuthority } from "../runtime-authority.js";
import type { CronScheduledToolPolicy } from "../scheduled-tool-policy.js";
@@ -124,7 +123,6 @@ export type CronServiceDeps = {
/** List enabled, configured channel ids without exposing channel machinery to cron core. */
listConfiguredChannels?: () => readonly string[] | Promise<readonly string[]>;
evaluateCronTrigger?: (params: {
deliveryAttemptFence: CronCompletionDeliveryFence | null;
job: CronStoredJob;
script: string;
state: unknown;
@@ -194,7 +192,6 @@ export type CronServiceDeps = {
opts: HeartbeatWakeRequest & { agentId: string },
) => number | undefined;
runIsolatedAgentJob: (params: {
deliveryAttemptFence: CronCompletionDeliveryFence | null;
job: CronJob;
admissionSource?: AdmittedRunContext["admissionSource"];
message: string;
@@ -213,12 +210,10 @@ export type CronServiceDeps = {
}
>;
runCommandJob?: (params: {
deliveryAttemptFence: CronCompletionDeliveryFence | null;
job: CronJob;
abortSignal?: AbortSignal;
}) => Promise<CronRunOutcome & CronRunDeliveryResult>;
runScriptJob?: (params: {
deliveryAttemptFence: CronCompletionDeliveryFence | null;
job: CronStoredJob;
streamBatch?: string;
abortSignal?: AbortSignal;
@@ -239,7 +234,6 @@ export type CronServiceDeps = {
event: CronEvent;
abortSignal: AbortSignal;
onDeliveryState: (outcome: CronWebhookDeliveryOutcome) => void;
assertCurrent?: () => void;
}) => Promise<CronWebhookDeliveryOutcome>;
cleanupTimedOutAgentRun?: (params: {
job: CronJob;
@@ -5,7 +5,6 @@ import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../../routing/se
import { deliveryContextFromSession } from "../../utils/delivery-context.read.js";
import type { DeliveryContext } from "../../utils/delivery-context.types.js";
import type { CronActiveJobMarker } from "../active-jobs.js";
import type { CronCompletionDeliveryFence } from "../delivery-attempt-fence.js";
import type { CronRunReceiptSettlementDisposition } from "../store/run-receipt-store.js";
import type { CronRunReceiptHandle } from "../store/run-receipt.types.js";
import type {
@@ -122,7 +121,6 @@ export type StartupCatchupExecution =
| { ok: false; outcomes: TimedCronRunOutcome[]; error: unknown };
export type ExecuteJobCoreOptions = {
deliveryAttemptFence?: CronCompletionDeliveryFence;
activeJobMarker?: CronActiveJobMarker;
owningCronLaneTaskMarker?: CommandLaneTaskMarker;
onPayloadExecutionStarted?: () => void;
-4
View File
@@ -89,7 +89,6 @@ export async function executeJobCore(
};
}
const evaluation = await evaluator({
deliveryAttemptFence: options?.deliveryAttemptFence ?? null,
job,
script: job.trigger.script,
state: job.state.triggerState,
@@ -347,7 +346,6 @@ async function executeDetachedCronJob(
};
}
const res = await state.deps.runCommandJob({
deliveryAttemptFence: options?.deliveryAttemptFence ?? null,
job,
abortSignal,
});
@@ -394,7 +392,6 @@ async function executeDetachedCronJob(
}
const res = await state.deps.runIsolatedAgentJob({
deliveryAttemptFence: options?.deliveryAttemptFence ?? null,
job,
admissionSource:
job.owner?.sessionKey ||
@@ -464,7 +461,6 @@ async function executeScriptCronJob(
};
}
const result = await state.deps.runScriptJob({
deliveryAttemptFence: options?.deliveryAttemptFence ?? null,
job,
streamBatch: options?.streamBatch,
abortSignal,
+1 -22
View File
@@ -7,7 +7,6 @@ import {
isCronActiveJobMarkerCurrent,
} from "../active-jobs.js";
import { resolveAdmittedCronCompletionStatus } from "../completion-status.js";
import type { CronCompletionDeliveryFence } from "../delivery-attempt-fence.js";
import { resolveCronDeliveryPlan } from "../delivery-plan.js";
import { createCronRunDiagnosticsFromError } from "../run-diagnostics.js";
import type { CronAgentExecutionStarted, CronJob } from "../types.js";
@@ -20,7 +19,6 @@ import {
CRON_AGENT_SETUP_WATCHDOG_MS,
settleTimedOutCronRun,
} from "./agent-watchdog.js";
import { createCronCompletionDeliveryFence } from "./delivery-attempt-fence.js";
import { abortErrorMessage, isSetupTimeoutErrorText } from "./execution-errors.js";
import {
assertServiceCronRunReceiptCurrent,
@@ -62,7 +60,6 @@ async function deliverPrimaryWebhook(
abortSignal: AbortSignal,
progress: CronRunProgress,
assertRunCurrent?: () => void,
deliveryAttemptFence?: CronCompletionDeliveryFence,
): Promise<CronCoreRunOutcome> {
const settle = (settledResult: CronCoreRunOutcome) => {
// Publish the terminal delivery fact before this async function resolves;
@@ -106,13 +103,8 @@ async function deliverPrimaryWebhook(
result,
outcome: { status: "delivered" },
});
let webhookAdapterEntered = false;
try {
await deliveryAttemptFence?.beforeAttempt();
deliveryAttemptFence?.assertCurrent();
webhookAdapterEntered = true;
const outcome = await state.deps.sendCronWebhook({
assertCurrent: deliveryAttemptFence?.assertCurrent,
job,
abortSignal,
onDeliveryState: (delivery) => {
@@ -160,9 +152,7 @@ async function deliverPrimaryWebhook(
withPrimaryWebhookTrace({
job,
result,
outcome: progress.webhookDelivery ?? {
status: webhookAdapterEntered ? "unknown" : "not-delivered",
},
outcome: progress.webhookDelivery ?? { status: "unknown" },
error: deliveryError,
}),
);
@@ -181,15 +171,6 @@ async function executeJobCoreWithTimeoutUnfinalized(
const assertRunCurrent = opts?.runReceipt
? () => assertServiceCronRunReceiptCurrent(state, opts.runReceipt!, opts.activeJobMarker)
: undefined;
const deliveryAttemptFence = opts?.runReceipt
? createCronCompletionDeliveryFence({
state,
job,
handle: opts.runReceipt,
activeJobMarker: opts.activeJobMarker,
signal: runAbortController.signal,
})
: undefined;
const operatorCancellationMarker = Symbol("cron-operator-cancelled");
const operatorCancellation = createDeferredCore<typeof operatorCancellationMarker>();
const createInterruptionOutcome = async (
@@ -311,7 +292,6 @@ async function executeJobCoreWithTimeoutUnfinalized(
const resolveHeartbeatTimeoutMs = state.deps.resolveHeartbeatTimeoutMs;
const executionIdentity = opts?.executionIdentity;
const coreOptions: ExecuteJobCoreOptions = {
deliveryAttemptFence,
activeJobMarker: opts?.activeJobMarker,
owningCronLaneTaskMarker: opts?.owningCronLaneTaskMarker,
streamBatch: opts?.streamBatch,
@@ -356,7 +336,6 @@ async function executeJobCoreWithTimeoutUnfinalized(
runAbortController.signal,
progress,
assertRunCurrent,
deliveryAttemptFence,
);
});
// Timeout/cancel projects an outcome before an abort-ignoring core settles;
-1
View File
@@ -372,7 +372,6 @@ describe("cron service timer seam coverage", () => {
expect(runCommandJob).toHaveBeenCalledWith({
job,
abortSignal: undefined,
deliveryAttemptFence: null,
});
expect(runIsolatedAgentJob).not.toHaveBeenCalled();
});
-5
View File
@@ -30,7 +30,6 @@ export function prepareCronStateWorkerCommand(type: PropertyKey): Promise<void>
"cron.reserveRuns",
"cron.activateRun",
"cron.releaseReservations",
"cron.markDeliveryStarted",
"cron.finishReceipt",
"cron.removeStaleFamily",
].includes(String(type)) &&
@@ -67,7 +66,6 @@ export function isCronStateWorkerCommand(command: {
case "cron.recordRun":
case "cron.activateRun":
case "cron.releaseReservations":
case "cron.markDeliveryStarted":
case "cron.finishReceipt":
case "cron.removeStaleFamily":
case "cron.loadMutable":
@@ -104,7 +102,6 @@ export function executeCronStateCommand(
case "cron.reserveRuns":
case "cron.activateRun":
case "cron.releaseReservations":
case "cron.markDeliveryStarted":
case "cron.finishReceipt":
case "cron.removeStaleFamily":
if (!admission) {
@@ -117,8 +114,6 @@ export function executeCronStateCommand(
return admission.activateCronRunInWorker(database, command.input);
case "cron.releaseReservations":
return admission.releaseCronReservationsInWorker(database, command.input);
case "cron.markDeliveryStarted":
return admission.markCronDeliveryStartedInWorker(database, command.input);
case "cron.finishReceipt":
return admission.finishCronReceiptInWorker(database, command.input);
case "cron.removeStaleFamily":
-34
View File
@@ -15,7 +15,6 @@ import {
loadCronRows,
upsertCronJobRow,
} from "./row-codec.js";
import { markCronDeliveryStartedInDatabase } from "./run-receipt-delivery.js";
import { readActiveCronRunReceiptsInDatabase } from "./run-receipt-read.js";
import {
activateCronRunReceiptInDatabase,
@@ -318,39 +317,6 @@ export function finishCronReceiptInWorker(
);
}
export function markCronDeliveryStartedInWorker(
database: OpenClawStateDatabase,
input: CronRuntimeWorkerOperations["cron.markDeliveryStarted"]["input"],
) {
return runOpenClawStateWriteTransaction(
({ db }) => {
if (input.storeKey !== input.handle.storeKey) {
throw new CronRunReceiptRevisionError(input.handle.receiptId);
}
const deletionBlocked = isAgentDeletionBlocked(input.handle.agentId, {}, db);
const preparation = prepareCronRuntimeMutation("cron.markDeliveryStarted", input.nonce, {
deletionBlocked,
});
if (deletionBlocked) {
throw new CronRunReceiptRevisionError(
input.handle.receiptId,
"cron agent is unavailable",
"owner-unavailable",
);
}
markCronDeliveryStartedInDatabase({
database: db,
handle: input.handle,
allowMissingJob: preparation.allowMissingJob,
resolveAgentId: (job) => resolveCronJobEffectiveAgentId(job, preparation.defaultAgentId),
});
return retainCronRuntimeMutationOutcome("cron.markDeliveryStarted", db, input.nonce, {});
},
{ database, path: database.path, env: getSqliteWorkerStateContext().environment },
{ operationLabel: "cron.run-receipt.mark-delivery-started" },
);
}
export function removeStaleCronFamilyInWorker(
database: OpenClawStateDatabase,
input: CronRuntimeWorkerOperations["cron.removeStaleFamily"]["input"],
-156
View File
@@ -1,156 +0,0 @@
import { expect, it, vi } from "vitest";
import { loseFirstCronMutationReply } from "../../../test/helpers/cron/runtime-mutation.js";
import * as workerAdmission from "../../infra/sqlite-worker-operation-admission.js";
import { openOpenClawStateDatabase } from "../../state/openclaw-state-db.js";
import { captureOpenClawStateWorkerContext } from "../../state/openclaw-state-worker-context.js";
import { setupCronServiceSuite, writeCronStoreSnapshot } from "../service.test-harness.js";
import { start, stop } from "../service/ops-lifecycle.js";
import { makeCronRecoveryState } from "../service/run-recovery.test-support.js";
import { runCronRuntimeMutation } from "../service/runtime-mutation.js";
import { loadCronStore } from "../store.js";
import {
finishCronRunReceiptAsync,
releaseLocalCronRunReceiptOwnership,
} from "./run-receipt-store.js";
import {
claimCronRunReceiptForTest,
makeCronReceiptJob,
makeCronRecoveryJob,
} from "./run-receipt-store.test-support.js";
const { logger, makeStorePath } = setupCronServiceSuite({ prefix: "cron-delivery-commit-" });
it.each(["commit", "retired", "reply-lost", "evidence-lost"] as const)(
"publishes delivery admission only from confirmed current commit: %s",
async (outcome) => {
const { storePath } = await makeStorePath();
const job = makeCronReceiptJob("delivery-commit");
await writeCronStoreSnapshot({ storePath, jobs: [job] });
const receipt = claimCronRunReceiptForTest(storePath, job, Date.now());
const readPhase = () =>
openOpenClawStateDatabase()
.db.prepare("SELECT delivery_attempt_state FROM cron_run_receipts WHERE receipt_id = ?")
.get(receipt.receiptId)?.delivery_attempt_state;
expect(readPhase()).toBe("not-started");
const reply =
outcome === "reply-lost" || outcome === "evidence-lost"
? loseFirstCronMutationReply("cron.markDeliveryStarted")
: undefined;
const createAdmission = workerAdmission.createSqliteWorkerOperationAdmission;
const lostEvidence =
outcome === "evidence-lost"
? vi
.spyOn(workerAdmission, "createSqliteWorkerOperationAdmission")
.mockImplementation((...args) => {
const admission = createAdmission(...args);
return {
...admission,
get committed() {
return undefined;
},
get settlement() {
return { kind: "unknown" as const };
},
};
})
: undefined;
const published = vi.fn();
const settled = vi.fn();
try {
const attempt = runCronRuntimeMutation({
context: captureOpenClawStateWorkerContext(),
type: "cron.markDeliveryStarted",
input: { storeKey: receipt.storeKey, handle: receipt },
assertCurrent: () => undefined,
prepare: ({ deletionBlocked }) => {
expect(deletionBlocked).toBe(false);
return {
value: { allowMissingJob: false },
assertCurrent: () => {
if (outcome === "retired") {
throw new Error("delivery owner retired before commit");
}
},
};
},
publish: published,
onSettled: settled,
});
if (outcome === "commit") {
await attempt;
} else {
await expect(attempt).rejects.toBeInstanceOf(Error);
}
await reply?.waitForExit();
expect(readPhase()).toBe(outcome === "retired" ? "not-started" : "started");
expect(published).toHaveBeenCalledTimes(
outcome === "commit" || outcome === "reply-lost" ? 1 : 0,
);
expect(settled).toHaveBeenCalledWith(
outcome === "evidence-lost"
? "unknown"
: outcome === "retired"
? "not-committed"
: "committed",
);
if (reply) {
expect(reply.wasDropped()).toBe(true);
expect(reply.attempts).toEqual(["cron.markDeliveryStarted"]);
}
} finally {
await reply?.close();
lostEvidence?.mockRestore();
await finishCronRunReceiptAsync({
handle: receipt,
status: "interrupted",
finishedAtMs: Date.now(),
});
}
},
);
it.each(["legacy-running", "legacy-terminal", "started-terminal", "receiptless"] as const)(
"holds an interrupted ambiguous one-shot disabled across repeated startup: %s",
async (kind) => {
const { storePath } = await makeStorePath();
const startedAtMs = Date.now();
const job = makeCronRecoveryJob("ambiguous-one-shot", startedAtMs);
job.schedule = { kind: "at", at: new Date(startedAtMs).toISOString() };
job.deleteAfterRun = true;
await writeCronStoreSnapshot({ storePath, jobs: [job] });
if (kind !== "receiptless") {
const receipt = claimCronRunReceiptForTest(storePath, job, startedAtMs);
job.state.runningReceiptId = receipt.receiptId;
await writeCronStoreSnapshot({ storePath, jobs: [job] });
openOpenClawStateDatabase()
.db.prepare("UPDATE cron_run_receipts SET delivery_attempt_state = ? WHERE receipt_id = ?")
.run(kind === "started-terminal" ? "started" : "unknown", receipt.receiptId);
if (kind !== "legacy-running") {
await finishCronRunReceiptAsync({
handle: receipt,
status: "interrupted",
finishedAtMs: startedAtMs + 1,
});
}
releaseLocalCronRunReceiptOwnership(receipt);
}
for (let restart = 0; restart < 2; restart += 1) {
const state = makeCronRecoveryState(logger, storePath, startedAtMs + 2);
state.deps.runCommandJob = vi.fn(async () => ({ status: "ok" as const }));
try {
await start(state);
const recovered = (await loadCronStore(storePath)).jobs[0];
expect(recovered).toMatchObject({
enabled: false,
state: { lastRunStatus: "error", lastDeliveryStatus: "unknown", consecutiveErrors: 1 },
});
expect(recovered?.state.runningAtMs).toBeUndefined();
expect(recovered?.state.nextRunAtMs).toBeUndefined();
expect(recovered?.state.startupCatchupAtMs).toBeUndefined();
expect(state.deps.runCommandJob).not.toHaveBeenCalled();
} finally {
stop(state);
}
}
},
);
-68
View File
@@ -1,68 +0,0 @@
import type { DatabaseSync } from "node:sqlite";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../../infra/kysely-sync.js";
import type { CronJob } from "../types.js";
import { loadCronRows } from "./row-codec.js";
import type { CronRunReceiptDatabase } from "./run-receipt-read.js";
import {
assertCronRunReceiptCurrentInDatabase,
assertCronRunReceiptOwnedInDatabase,
} from "./run-receipt-store.js";
import type { CronRunReceiptHandle } from "./run-receipt.types.js";
type CronDeliveryAttemptState = "unknown" | "not-started" | "started";
/** Recovery reads the referenced receipt, including retained terminal receipts, in its write snapshot. */
export function readCronDeliveryAttemptStateInDatabase(params: {
database: DatabaseSync;
storeKey: string;
jobId: string;
receiptId: string | undefined;
startedAtMs: number;
}): CronDeliveryAttemptState {
if (!params.receiptId) {
return "unknown";
}
const row = executeSqliteQueryTakeFirstSync(
params.database,
getNodeSqliteKysely<CronRunReceiptDatabase>(params.database)
.selectFrom("cron_run_receipts")
.select("delivery_attempt_state")
.where("receipt_id", "=", params.receiptId)
.where("store_key", "=", params.storeKey)
.where("job_id", "=", params.jobId)
.where("started_at_ms", "=", params.startedAtMs),
);
return row?.delivery_attempt_state === "not-started" || row?.delivery_attempt_state === "started"
? row.delivery_attempt_state
: "unknown";
}
/** An exact live receipt owns the monotonic possible-delivery fact; this is not a success verdict. */
export function markCronDeliveryStartedInDatabase(params: {
database: DatabaseSync;
handle: CronRunReceiptHandle;
allowMissingJob: boolean;
resolveAgentId: (job: CronJob) => string;
}): void {
const removedByThisRun =
params.allowMissingJob &&
loadCronRows(params.database, params.handle.storeKey, new Set([params.handle.jobId])).length ===
0;
if (removedByThisRun) {
assertCronRunReceiptOwnedInDatabase(params);
} else {
assertCronRunReceiptCurrentInDatabase(params);
}
executeSqliteQuerySync(
params.database,
getNodeSqliteKysely<CronRunReceiptDatabase>(params.database)
.updateTable("cron_run_receipts")
.set({ delivery_attempt_state: "started" })
.where("receipt_id", "=", params.handle.receiptId)
.where("delivery_attempt_state", "!=", "started"),
);
}
-1
View File
@@ -428,7 +428,6 @@ export function claimCronRunReceiptInDatabase(params: {
config_revision: handle.configRevision,
agent_id: handle.agentId,
request_run_id: params.prepared.requestRunId ?? null,
delivery_attempt_state: "not-started",
status: "running",
owner_pid: handle.ownerPid,
owner_start_time: handle.ownerStartTime,
+3 -12
View File
@@ -9,7 +9,6 @@ import {
import type { CronJobPolicyContext, DeferredCronNotifications } from "../service/state.js";
import type { CronJob } from "../types.js";
import { deleteCronJobRowInDatabase, upsertCronJobRow } from "./row-codec.js";
import { readCronDeliveryAttemptStateInDatabase } from "./run-receipt-delivery.js";
import {
findActiveCronRunReceiptInDatabase,
finishCronRunReceiptInDatabase,
@@ -148,15 +147,7 @@ export function repairCronRunInDatabase(params: {
taskRunId: task.taskRunId,
runningAtMs: proposal.runningAtMs,
nowMs,
recoverInterruptedOneShot:
params.mode === "startup" &&
readCronDeliveryAttemptStateInDatabase({
database: database.db,
storeKey,
jobId: job.id,
receiptId: job.state.runningReceiptId ?? currentReceipt?.receiptId,
startedAtMs: proposal.runningAtMs,
}) === "not-started",
recoverInterruptedOneShot: params.mode === "startup",
deferredNotifications: notifications,
});
replacementAtMs = interrupted.replacementAtMs;
@@ -168,7 +159,7 @@ export function repairCronRunInDatabase(params: {
deferredNotifications: notifications,
});
}
if (params.mode === "startup" && job.schedule.kind === "at" && job.enabled) {
if (params.mode === "startup" && job.schedule.kind === "at") {
// Commit the pending occurrence with receipt retirement, so another
// restart before admission cannot consume it as terminal run history.
job.state.startupCatchupAtMs = job.state.nextRunAtMs;
@@ -224,7 +215,7 @@ export function repairCronRunInDatabase(params: {
notifications,
...(replacementAtMs === undefined &&
proposal.runningAtMs !== undefined &&
!(params.mode === "startup" && interrupted && job.schedule.kind === "at" && job.enabled)
!(params.mode === "startup" && interrupted && job.schedule.kind === "at")
? { skipStartupCatchup: true }
: {}),
};
-6
View File
@@ -58,12 +58,6 @@ export type CronRuntimeMutationContracts = {
logs: CronRunRecoveryOutcome["logs"];
};
};
"cron.markDeliveryStarted": {
input: CronRuntimeMutationInputs["cron.markDeliveryStarted"];
facts: { deletionBlocked: boolean };
preparation: { allowMissingJob: boolean; defaultAgentId?: string };
outcome: Record<string, never>;
};
"cron.finishReceipt": {
input: CronRuntimeMutationInputs["cron.finishReceipt"];
facts: Record<string, never>;
-4
View File
@@ -54,10 +54,6 @@ export type CronRuntimeMutationInputs = {
terminal?: CronReceiptTerminal;
requireCurrentReceipt?: boolean;
};
"cron.markDeliveryStarted": {
storeKey: string;
handle: CronRunReceiptHandle;
};
"cron.finishReceipt": {
storeKey: string;
terminal: CronReceiptTerminal;
+104
View File
@@ -0,0 +1,104 @@
import { afterAll, afterEach, beforeAll, expect, it, vi } from "vitest";
import type { AdmittedRunContext } from "../agents/admitted-run-context.js";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "../config/runtime-snapshot.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
captureActivePluginRegistrySnapshot,
restoreActivePluginRegistrySnapshot,
setActivePluginRegistry,
} from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import { createOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import { bindCronJobAdmittedRun, clearCronJobActive, markCronJobActive } from "./active-jobs.js";
import { createCronScriptRuntimeFixture } from "./trigger-script.test-helpers.js";
let state: Awaited<ReturnType<typeof createOpenClawTestState>>;
let registrySnapshot: ReturnType<typeof captureActivePluginRegistrySnapshot>;
beforeAll(async () => {
state = await createOpenClawTestState({ prefix: "openclaw-cron-script-message-" });
registrySnapshot = captureActivePluginRegistrySnapshot();
});
afterEach(() => {
clearRuntimeConfigSnapshot();
restoreActivePluginRegistrySnapshot(registrySnapshot);
});
afterAll(async () => {
await state.cleanup();
});
it.each(["trigger", "payload"] as const)(
"allows %s messages only while the scheduled occurrence owns authority",
async (mode) => {
const config: OpenClawConfig = {
agents: { defaults: { workspace: state.workspaceDir, skipBootstrap: true } },
tools: { allow: ["message"] },
channels: { telegram: { enabled: true, botToken: "synthetic-cron-token" } },
};
setRuntimeConfigSnapshot(config, config);
const sendText = vi.fn(async () => ({ channel: "telegram" as const, messageId: "sent-1" }));
const registry = createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: {
...createChannelTestPluginBase({ id: "telegram" }),
messaging: { targetResolver: { looksLikeId: (value: string) => value === "123" } },
actions: { describeMessageTool: () => ({ actions: ["send"] }) },
outbound: {
deliveryMode: "direct",
resolveTarget: ({ to }: { to?: string }) => ({ ok: true, to: to ?? "123" }),
sendText,
},
},
},
]);
setActivePluginRegistry(registry);
const jobId = `script-message-${mode}`;
let authorityCurrent = true;
const marker = markCronJobActive(jobId, {
isMessageActionAuthorityCurrent: () => authorityCurrent,
});
const controller = new AbortController();
const runtime = createCronScriptRuntimeFixture({
config,
loadPluginRegistry: () => registry,
});
const params = {
jobId,
script:
'await message({ action: "send", channel: "telegram", target: "123", message: "Scheduled report" }); return { fire: false };',
state: null,
toolsAllow: ["message"],
scheduledToolPolicy: { version: 1, mode: "trusted" } as const,
abortSignal: controller.signal,
executionIdentity: {
ingress: { kind: "schedule", boundary: "cron.script", state: "present" } as const,
onPostAdmission: (admitted: AdmittedRunContext) => {
bindCronJobAdmittedRun(marker, admitted, controller.signal);
},
},
};
const invoke = () =>
mode === "trigger" ? runtime.evaluateTrigger(params) : runtime.executePayload(params);
try {
await expect(invoke()).resolves.toMatchObject({
kind: mode === "trigger" ? "evaluated" : "completed",
});
expect(sendText).toHaveBeenCalledOnce();
authorityCurrent = false;
await expect(invoke()).resolves.toMatchObject({
kind: "error",
error: expect.stringContaining("cron message action authority is no longer active"),
});
expect(sendText).toHaveBeenCalledOnce();
} finally {
clearCronJobActive(jobId, marker);
}
},
);
-2
View File
@@ -42,7 +42,6 @@ export function createCronScriptRuntimeFixture(deps: RuntimeDeps) {
return {
evaluateTrigger: (params: ScriptFixture) =>
runtime.evaluateTrigger({
deliveryAttemptFence: null,
job: job(params, { kind: "agentTurn", message: "condition payload" }),
script: params.script,
state: params.state,
@@ -52,7 +51,6 @@ export function createCronScriptRuntimeFixture(deps: RuntimeDeps) {
}),
executePayload: (params: ScriptFixture) =>
runtime.executePayload({
deliveryAttemptFence: null,
job: job(params, {
kind: "script",
script: params.script,
-1
View File
@@ -423,7 +423,6 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) {
agentId: runtime.context.agentId,
sessionKey: runtime.context.sessionKey,
jobId: params.job.id,
deliveryAttemptFence: params.deliveryAttemptFence ?? null,
toolsAllow: request.toolsAllow,
scheduledToolPolicy: request.scheduledToolPolicy,
channelRequester: resolveCronAuthenticatedChannelRequester(params.job),
+35 -44
View File
@@ -250,49 +250,6 @@ export async function decodeLaunchdPlistMetadata(
return asOptionalRecord(JSON.parse(stdout));
}
/** Resolve captured plist metadata through the same generated environment owner as file reads. */
export async function resolveLaunchAgentProgramArguments(
plist: Awaited<ReturnType<typeof decodeLaunchdPlistMetadata>>,
plistPath: string,
options?: ReadLaunchAgentProgramArgumentsOptions,
): Promise<GatewayServiceCommandConfig> {
const args = plist?.ProgramArguments;
const workingDirectory = plist?.WorkingDirectory;
const inlineEnvironment = plist?.EnvironmentVariables;
if (
!Array.isArray(args) ||
!args.every((arg): arg is string => typeof arg === "string") ||
(workingDirectory !== undefined && typeof workingDirectory !== "string") ||
(inlineEnvironment !== undefined && !isStringRecord(inlineEnvironment))
) {
throw new Error("Invalid LaunchAgent command fields");
}
const layout = resolveGeneratedEnvWrapperLayout(args, options);
const fileEnvironment = await readLaunchAgentEnvironmentFile(layout?.envFilePath, options);
const effectiveProgramArguments = layout ? args.slice(layout.commandStartIndex) : args;
if (options?.requireEffective && !effectiveProgramArguments[0]) {
throw new Error("Missing LaunchAgent command");
}
const environment = { ...inlineEnvironment, ...fileEnvironment };
const environmentValueSources: Record<string, GatewayServiceEnvironmentValueSource> = {};
// Track source provenance so repair flows can tell inline plist env from the
// generated env file and preserve both when they overlap.
for (const key of Object.keys(environment)) {
environmentValueSources[key] = !Object.hasOwn(fileEnvironment, key)
? "inline"
: Object.hasOwn(inlineEnvironment ?? {}, key)
? "inline-and-file"
: "file";
}
return {
programArguments: effectiveProgramArguments,
...(workingDirectory ? { workingDirectory } : {}),
...(Object.keys(environment).length > 0 ? { environment } : {}),
...(Object.keys(environmentValueSources).length > 0 ? { environmentValueSources } : {}),
sourcePath: plistPath,
};
}
export async function readLaunchAgentProgramArgumentsFromFile(
plistPath: string,
options?: ReadLaunchAgentProgramArgumentsOptions,
@@ -317,7 +274,41 @@ export async function readLaunchAgentProgramArgumentsFromFile(
return null;
}
const plist = await decodeLaunchdPlistMetadata(contents, options?.timeoutMs);
return await resolveLaunchAgentProgramArguments(plist, plistPath, options);
const args = plist?.ProgramArguments;
const workingDirectory = plist?.WorkingDirectory;
const inlineEnvironment = plist?.EnvironmentVariables;
if (
!Array.isArray(args) ||
!args.every((arg): arg is string => typeof arg === "string") ||
(workingDirectory !== undefined && typeof workingDirectory !== "string") ||
(inlineEnvironment !== undefined && !isStringRecord(inlineEnvironment))
) {
throw new Error("Invalid LaunchAgent command fields");
}
const layout = resolveGeneratedEnvWrapperLayout(args, options);
const fileEnvironment = await readLaunchAgentEnvironmentFile(layout?.envFilePath, options);
const effectiveProgramArguments = layout ? args.slice(layout.commandStartIndex) : args;
if (options?.requireEffective && !effectiveProgramArguments[0]) {
throw new Error("Missing LaunchAgent command");
}
const environment = { ...inlineEnvironment, ...fileEnvironment };
const environmentValueSources: Record<string, GatewayServiceEnvironmentValueSource> = {};
// Track source provenance so repair flows can tell inline plist env from the
// generated env file and preserve both when they overlap.
for (const key of Object.keys(environment)) {
environmentValueSources[key] = !Object.hasOwn(fileEnvironment, key)
? "inline"
: Object.hasOwn(inlineEnvironment ?? {}, key)
? "inline-and-file"
: "file";
}
return {
programArguments: effectiveProgramArguments,
...(workingDirectory ? { workingDirectory } : {}),
...(Object.keys(environment).length > 0 ? { environment } : {}),
...(Object.keys(environmentValueSources).length > 0 ? { environmentValueSources } : {}),
sourcePath: plistPath,
};
} catch (error) {
if (hasCommandProcessCleanupError(error)) {
throw error;
-170
View File
@@ -1,12 +1,9 @@
/** launchctl state parsing, inspection, and bootstrap primitives. */
import fs from "node:fs/promises";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import {
parseStrictInteger,
parseStrictPositiveInteger,
} from "@openclaw/normalization-core/number-coercion";
import { isStringRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { parseTcpPort, parseTcpPortFromArgs } from "../infra/tcp-port.js";
import { hasCommandProcessCleanupError } from "../process/exec-result.js";
@@ -23,10 +20,7 @@ import {
import { resolveLaunchAgentLabel } from "./launchd-label.js";
import {
LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS,
decodeLaunchdPlistMetadata,
readLaunchAgentProgramArgumentsFromFile,
resolveLaunchAgentProgramArguments,
resolveGeneratedEnvWrapperLayout,
} from "./launchd-plist.js";
import {
resolveLaunchAgentPlistPath,
@@ -48,172 +42,8 @@ import type {
GatewayServiceEnv,
GatewayServiceEnvArgs,
GatewayServiceReadOptions,
GatewayServiceState,
} from "./service-types.js";
export type LoadedLaunchAgentState = GatewayServiceState & {
launchAgent?: {
readonly target: string;
readonly sourcePath: string;
readonly program: string;
readonly programArguments: readonly string[];
readonly workingDirectory?: string;
readonly environment: Readonly<Record<string, string>>;
};
};
/** Parse only the selected job's root fields; nested jobs and environments are not its identity. */
export function parseLaunchctlJob(output: string, serviceTarget: string) {
if (!output.startsWith(`${serviceTarget} = {\n`)) {
throw new Error(`Cannot parse launchd job ${serviceTarget}`);
}
const fields = new Map<string, string>();
for (const [, name, value] of output.matchAll(/^\t([^\t\n=]+) = (.+)$/gm)) {
if (fields.has(name!)) {
throw new Error(`Duplicate launchd job field ${name}`);
}
fields.set(name!, value!);
}
const block = (name: string) =>
output.match(new RegExp(`^\\t${name} = \\{\\n([\\s\\S]*?)^\\t\\}`, "m"))?.[1];
return {
fields,
arguments: block("arguments")
?.split("\n")
.filter((line) => line.startsWith("\t\t"))
.map((line) => line.slice(2)),
environment: block("environment") ?? "",
};
}
/** Observe the discovered job's loaded command and runtime without granting lifecycle authority. */
export async function readLoadedLaunchAgentState(
env: GatewayServiceEnv,
options: { plistPath?: string; timeoutMs?: number } = {},
): Promise<LoadedLaunchAgentState> {
const label = resolveLaunchAgentLabel(env);
const expectedPath = options.plistPath ?? resolveLaunchAgentPlistPath(env);
const domain =
path.dirname(expectedPath) === "/Library/LaunchDaemons"
? "system"
: resolveLaunchAgentGuiDomain();
const target = `${domain}/${label}`;
const result = await execLaunchctl(["print", target], options.timeoutMs ?? 5_000);
const empty: GatewayServiceState = {
installed: false,
loadState: { status: "not-loaded" },
running: false,
env,
command: null,
runtime: { status: "stopped" },
};
if (isLaunchctlNotLoaded(result)) {
return empty;
}
if (result.code !== 0) {
throw new Error(`Cannot inspect launchd job ${target}: ${formatLaunchctlResultDetail(result)}`);
}
const job = parseLaunchctlJob(result.stdout, target);
const sourcePath = job.fields.get("path");
if (!sourcePath || !path.isAbsolute(sourcePath)) {
throw new Error("Loaded LaunchAgent definition path is unavailable.");
}
const program = job.fields.get("program");
if (!program || !path.isAbsolute(program) || !job.arguments?.length) {
throw new Error("Loaded LaunchAgent command is unavailable.");
}
const args = [program, ...job.arguments.slice(1)];
const layout = resolveGeneratedEnvWrapperLayout(
args,
resolveLaunchAgentEnvironmentReadOptions(env, label),
);
const environment: Record<string, string> = {};
for (const line of job.environment.split("\n").filter(Boolean)) {
const match = /^\t\t([A-Za-z_][A-Za-z0-9_]*) => (.*)$/.exec(line);
if (!match || Object.hasOwn(environment, match[1]!)) {
throw new Error("Loaded LaunchAgent environment is unavailable.");
}
environment[match[1]!] = match[2]!;
}
const command: GatewayServiceCommandConfig = {
programArguments: layout ? args.slice(layout.commandStartIndex) : args,
...(job.fields.get("working directory")
? { workingDirectory: job.fields.get("working directory") }
: {}),
...(Object.keys(environment).length ? { environment } : {}),
sourcePath,
};
const parsed = parseLaunchctlPrint(
[...job.fields].map(([name, value]) => `${name} = ${value}`).join("\n"),
);
const running = parsed.state === "running" || (parsed.pid !== undefined && parsed.pid > 1);
return {
installed: true,
loadState: { status: "loaded" },
running,
env: mergeGatewayServiceEnv(env, command),
command,
runtime: { ...parsed, status: running ? "running" : "stopped" },
launchAgent: {
target,
sourcePath,
program,
programArguments: job.arguments,
workingDirectory: command.workingDirectory,
environment,
},
};
}
/** Preserve the loaded-definition check used by a beta updater after package replacement. */
export async function readCorrespondingLaunchAgentCommand(
env: GatewayServiceEnv,
observation: NonNullable<LoadedLaunchAgentState["launchAgent"]>,
timeoutMs: number,
): Promise<GatewayServiceCommandConfig | null> {
const label = resolveLaunchAgentLabel(env);
const plistPath = resolveLaunchAgentPlistPath(env);
if (
observation.target !== `${resolveLaunchAgentGuiDomain()}/${label}` ||
path.resolve(observation.sourcePath) !== path.resolve(plistPath)
) {
return null;
}
const plist = await decodeLaunchdPlistMetadata(await fs.readFile(plistPath), timeoutMs);
const args = plist?.ProgramArguments;
const environment = plist?.EnvironmentVariables ?? {};
if (
!Array.isArray(args) ||
!args.every((arg): arg is string => typeof arg === "string") ||
!isStringRecord(environment) ||
(plist?.Program ?? args[0]) !== observation.program
) {
return null;
}
const loadedEnvironment = { ...observation.environment };
if (
!Object.hasOwn(environment, "XPC_SERVICE_NAME") &&
loadedEnvironment.XPC_SERVICE_NAME === label
) {
delete loadedEnvironment.XPC_SERVICE_NAME;
}
if (!Object.hasOwn(environment, "OSLogRateLimit")) {
delete loadedEnvironment.OSLogRateLimit;
}
if (
!isDeepStrictEqual(args, observation.programArguments) ||
(plist?.WorkingDirectory || undefined) !== observation.workingDirectory ||
!isDeepStrictEqual(environment, loadedEnvironment)
) {
return null;
}
return resolveLaunchAgentProgramArguments(plist, plistPath, {
...resolveLaunchAgentEnvironmentReadOptions(env, label),
requireEffective: true,
timeoutMs,
});
}
export async function readLaunchAgentProgramArguments(
env: GatewayServiceEnv,
options?: GatewayServiceReadOptions,
-5
View File
@@ -1,5 +0,0 @@
/** Private build edge for updater compatibility chunks; launchd-runtime owns behavior. */
export {
readCorrespondingLaunchAgentCommand,
readLoadedLaunchAgentState,
} from "./launchd-runtime.js";
@@ -26,12 +26,6 @@ type ScheduledMessageActionAuthority = {
channelRequester?: CronAuthenticatedChannelRequester;
};
/** Host-only delivery restriction; carries no channel, requester, or source privilege. */
type MessageActionDeliveryAttempt = {
beforeAttempt: () => Promise<void>;
assertCurrent: () => void;
};
/** Private handoff from authenticated dashboard admission to the exact reply run. */
export type DashboardMessageReadAdmission = Readonly<{
agentId: string;
@@ -47,8 +41,6 @@ export type MessageActionAuthorization = {
toolContext?: InternalChannelThreadingToolContext;
/** @internal Redeemed from the process-local turn capability. */
scheduled?: ScheduledMessageActionAuthority;
/** @internal Restricts writes independently of scheduled authorization. */
deliveryAttempt?: MessageActionDeliveryAttempt;
/** @internal Redeemed only by the host; never serialized or passed to plugins. */
assertDashboardReadCurrent?: () => void;
};
@@ -100,7 +92,6 @@ type MessageActionTurnCapability = AgentRuntimeMessageActionContext & {
runId: string;
sessionKey: string;
scheduled?: ScheduledMessageActionAuthority;
deliveryAttempt?: MessageActionDeliveryAttempt;
assertDashboardReadCurrent?: () => void;
};
@@ -196,7 +187,6 @@ export function mintMessageActionTurnCapability(params: {
requesterSenderE164?: string;
toolContext?: InternalChannelThreadingToolContext;
scheduled?: ScheduledMessageActionAuthority;
deliveryAttempt?: MessageActionDeliveryAttempt;
assertDashboardReadCurrent?: () => void;
expiresWithRun?: boolean;
ttlMs?: number;
@@ -257,22 +247,6 @@ export function mintMessageActionTurnCapability(params: {
: {}),
};
}
const deliveryAttempt = params.deliveryAttempt;
if (deliveryAttempt) {
capability.deliveryAttempt = {
assertCurrent: () => {
assertActive();
deliveryAttempt.assertCurrent();
},
beforeAttempt: async () => {
assertActive();
deliveryAttempt.assertCurrent();
await deliveryAttempt.beforeAttempt();
assertActive();
deliveryAttempt.assertCurrent();
},
};
}
const assertDashboardReadCurrent = params.assertDashboardReadCurrent;
if (assertDashboardReadCurrent) {
capability.assertDashboardReadCurrent = () => {
@@ -352,7 +326,6 @@ export function resolveMessageActionTurnAuthorization(
? {
...copyMessageActionTurnContext(capability),
scheduled: capability.scheduled,
deliveryAttempt: capability.deliveryAttempt,
assertDashboardReadCurrent: capability.assertDashboardReadCurrent,
}
: undefined;
-150
View File
@@ -1,150 +0,0 @@
import type { NormalizeReplySkipReason } from "../auto-reply/reply/normalize-reply-skip-reason.js";
import type { CliDeps } from "../cli/deps.types.js";
import { resolveControlUiAutomationRunUrl } from "../config/control-ui-link-base.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { CronCompletionDeliveryFence } from "../cron/delivery-attempt-fence.js";
import { resolveCronDeliveryPlan, sendCronAnnouncePayloadStrict } from "../cron/delivery.js";
import { retryTransientDirectCronDelivery } from "../cron/isolated-agent/delivery-dispatch-policy.js";
import { createCronExecutionId } from "../cron/run-id.js";
import { resolveCronDeliverySessionKey } from "../cron/session-target.js";
import type { CronDeliveryTrace, CronJob, CronResolvedDeliveryState } from "../cron/types.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { getChildLogger } from "../logging.js";
export function pickDefined<T extends Record<string, unknown>>(
obj: T,
keys: (keyof T)[],
): Partial<T> {
const result: Partial<T> = {};
for (const k of keys) {
if (obj[k] !== undefined) {
result[k] = obj[k];
}
}
return result;
}
export async function finalizeCronCompletionAnnouncement(params: {
deliveryAttemptFence: CronCompletionDeliveryFence | null;
job: CronJob;
text?: string;
suppressionReason?: NormalizeReplySkipReason;
runStartedAtMs?: number;
abortSignal?: AbortSignal;
deps: CliDeps;
resolveCronAgent: (requested?: string | null) => { agentId: string; cfg: OpenClawConfig };
logger: ReturnType<typeof getChildLogger>;
label: string;
traceResolvedFailure?: boolean;
}) {
const plan = resolveCronDeliveryPlan(params.job);
const delivery: CronDeliveryTrace = {
intended: pickDefined(
{
channel: plan.channel,
to: plan.to,
accountId: plan.accountId,
threadId: plan.threadId,
source: "explicit" as const,
},
["channel", "to", "accountId", "threadId", "source"],
),
};
if (plan.mode !== "announce") {
return { deliveryAttempted: false, delivered: false, delivery };
}
const deliveryState: CronResolvedDeliveryState = {
status: "not-delivered",
delivered: false,
failureNotification: { status: "not-requested" },
};
const finish = (deliveryAttempted: boolean) => ({
deliveryAttempted,
delivered: deliveryState.delivered,
deliveryError: deliveryState.error,
deliverySuppressionReason: deliveryState.deliverySuppressionReason,
deliveryState,
delivery: { ...delivery, delivered: deliveryState.delivered },
});
if (params.text === undefined) {
deliveryState.deliverySuppressionReason = params.suppressionReason ?? "empty";
return finish(false);
}
const { agentId, cfg } = params.resolveCronAgent(params.job.agentId);
const inspectUrl = resolveControlUiAutomationRunUrl(cfg, {
jobId: params.job.id,
runId:
params.runStartedAtMs === undefined
? undefined
: createCronExecutionId(params.job.id, params.runStartedAtMs),
});
const text = inspectUrl ? `${params.text}\nInspect: ${inspectUrl}` : params.text;
const abortSignal = params.abortSignal ?? new AbortController().signal;
let deliveryMayHaveReachedRecipient = false;
try {
const result = await retryTransientDirectCronDelivery({
jobId: params.job.id,
label: params.label,
signal: abortSignal,
shouldRetryError: () => !deliveryMayHaveReachedRecipient,
run: () =>
sendCronAnnouncePayloadStrict({
deps: params.deps,
cfg,
agentId,
jobId: params.job.id,
target: {
channel: plan.channel,
to: plan.to,
threadId: plan.threadId,
accountId: plan.accountId,
sessionKey: resolveCronDeliverySessionKey(params.job),
},
payload: { text },
abortSignal,
...(params.runStartedAtMs === undefined
? {}
: {
completion: {
job: params.job,
runStartedAt: params.runStartedAtMs,
deliveryAttemptFence: params.deliveryAttemptFence,
},
}),
onDeliveryAttempt: (reachedRecipient) => {
deliveryMayHaveReachedRecipient ||= reachedRecipient;
},
}),
});
if (result.status === "sent") {
deliveryState.status = "delivered";
deliveryState.delivered = true;
} else {
const uncertain = result.reason === "adapter_returned_no_identity";
deliveryState.status = uncertain ? "unknown" : "not-delivered";
deliveryState.delivered = uncertain ? undefined : false;
deliveryState.error = `cron delivery ${uncertain ? "outcome is unknown" : "was suppressed"}: ${result.reason}`;
}
return finish(true);
} catch (err) {
const deliveryError = formatErrorMessage(err);
params.logger.warn(
{ jobId: params.job.id, err: deliveryError },
`cron: ${params.label} delivery failed`,
);
deliveryState.error = deliveryError;
if (params.traceResolvedFailure) {
delivery.resolved = {
channel: plan.channel,
to: plan.to,
accountId: plan.accountId,
threadId: plan.threadId,
source: "explicit",
ok: false,
error: deliveryError,
};
}
return finish(true);
}
}
+1 -7
View File
@@ -201,7 +201,6 @@ async function postCronWebhookStrict(params: {
signal?: AbortSignal;
deadlineAtMs?: number;
onDeliveryState?: (outcome: CronWebhookDeliveryOutcome) => void;
assertCurrent?: () => void;
onResponse?: (status: number) => void;
}): Promise<void> {
const remainingMs =
@@ -218,10 +217,7 @@ async function postCronWebhookStrict(params: {
url: params.webhookUrl,
timeoutMs: requestTimeoutMs,
policy: params.ssrfPolicy,
beforeRequest: () => {
params.assertCurrent?.();
params.onDeliveryState?.({ status: "unknown" });
},
beforeRequest: () => params.onDeliveryState?.({ status: "unknown" }),
onResponse: params.onResponse,
...(params.signal ? { signal: params.signal } : {}),
init: {
@@ -299,7 +295,6 @@ export async function sendGatewayCronWebhook(params: {
webhookToken?: unknown;
ssrfPolicy?: SsrFPolicy;
onDeliveryState?: (outcome: CronWebhookDeliveryOutcome) => void;
assertCurrent?: () => void;
}): Promise<CronWebhookDeliveryOutcome> {
let outcome: CronWebhookDeliveryOutcome = { status: "not-delivered" };
const publish = (next: CronWebhookDeliveryOutcome) => {
@@ -329,7 +324,6 @@ export async function sendGatewayCronWebhook(params: {
signal: params.abortSignal,
deadlineAtMs: params.deadlineAtMs,
onDeliveryState: publish,
assertCurrent: params.assertCurrent,
onResponse: () => {
receivedResponse = true;
},
-1
View File
@@ -656,7 +656,6 @@ describe("buildGatewayCronService", () => {
try {
await getCronDeps(state).runIsolatedAgentJob({
deliveryAttemptFence: null,
job,
message: "review",
abortSignal: abortController.signal,
+147 -17
View File
@@ -10,8 +10,10 @@ import {
import { isEmbeddedAgentSessionHeldByOtherRun } from "../agents/embedded-agent-runner/runs.js";
import { abortAndDrainEmbeddedAgentRun } from "../agents/embedded-agent.js";
import { loadPreparedInboundPluginRegistry } from "../agents/prepared-model-runtime.inbound-registry.js";
import type { NormalizeReplySkipReason } from "../auto-reply/reply/normalize-reply-skip-reason.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import type { CliDeps } from "../cli/deps.types.js";
import { resolveControlUiAutomationRunUrl } from "../config/control-ui-link-base.js";
import { DEFAULT_CRON_ENABLED } from "../config/cron-limits.js";
import { getRuntimeConfig } from "../config/io.js";
import {
@@ -38,19 +40,30 @@ import {
} from "../cron/command-output-summary.js";
import { runCronCommandJob } from "../cron/command-runner.js";
import { resolveCronStoredDeliveryContext } from "../cron/delivery-context.js";
import { resolveCronDeliveryPlan, sendCronAnnouncePayloadStrict } from "../cron/delivery.js";
import { reconcileHeartbeatMonitorJobs } from "../cron/heartbeat-monitor.js";
import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js";
import { retryTransientDirectCronDelivery } from "../cron/isolated-agent/delivery-dispatch-policy.js";
import { resolveCronJobBoundSessionKeys } from "../cron/job-session-bindings.js";
import { toPublicCronJob } from "../cron/public-job.js";
import { createCronExecutionId } from "../cron/run-id.js";
import { cronScriptFailureMetadata } from "../cron/script-failure.js";
import { CronService, type CronEvent } from "../cron/service.js";
import { applyJobPatch } from "../cron/service/jobs.js";
import { resolveCronSessionTargetSessionKey } from "../cron/session-target.js";
import {
resolveCronDeliverySessionKey,
resolveCronSessionTargetSessionKey,
} from "../cron/session-target.js";
import { skillCollectionReviewMonitorAgentId } from "../cron/skill-collection-review-monitor.js";
import { resolveCronJobsStorePathFromConfig } from "../cron/store.js";
import { cronStreamScheduleKey } from "../cron/stream-schedule.js";
import { createCronScriptRuntime } from "../cron/trigger-script.js";
import type { CronJob, CronPayload } from "../cron/types.js";
import type {
CronDeliveryTrace,
CronJob,
CronPayload,
CronResolvedDeliveryState,
} from "../cron/types.js";
import { racePromiseWithAbortSignal } from "../infra/abort-signal.js";
import { formatErrorMessage } from "../infra/errors.js";
import { resolveMainScopedEventSessionKey } from "../infra/event-session-routing.js";
@@ -111,7 +124,6 @@ import {
createScheduledGatewayRunner,
fenceScheduledGatewayContextResolver,
} from "./scheduled-run-gateway-context.js";
import { finalizeCronCompletionAnnouncement, pickDefined } from "./server-cron-completion.js";
import type { GatewayCronServiceContract } from "./server-cron-contract.js";
import { drainGatewayCron } from "./server-cron-drain.js";
import {
@@ -231,6 +243,16 @@ function reconcileCronExitWatchers(params: {
params.exitWatchers.reconcile(params.jobs);
}
function pickDefined<T extends Record<string, unknown>>(obj: T, keys: (keyof T)[]): Partial<T> {
const result: Partial<T> = {};
for (const k of keys) {
if (obj[k] !== undefined) {
result[k] = obj[k];
}
}
return result;
}
function sanitizeCronHeartbeatOverride(
heartbeat: AgentDefaultsConfig["heartbeat"] | undefined,
): AgentDefaultsConfig["heartbeat"] | undefined {
@@ -239,6 +261,125 @@ function sanitizeCronHeartbeatOverride(
: heartbeat;
}
async function finalizeCronCompletionAnnouncement(params: {
job: CronJob;
text?: string;
suppressionReason?: NormalizeReplySkipReason;
runStartedAtMs?: number;
abortSignal?: AbortSignal;
deps: CliDeps;
resolveCronAgent: (requested?: string | null) => { agentId: string; cfg: OpenClawConfig };
logger: ReturnType<typeof getChildLogger>;
label: string;
traceResolvedFailure?: boolean;
}) {
const plan = resolveCronDeliveryPlan(params.job);
const delivery: CronDeliveryTrace = {
intended: pickDefined(
{
channel: plan.channel,
to: plan.to,
accountId: plan.accountId,
threadId: plan.threadId,
source: "explicit" as const,
},
["channel", "to", "accountId", "threadId", "source"],
),
};
if (plan.mode !== "announce") {
return { deliveryAttempted: false, delivered: false, delivery };
}
const deliveryState: CronResolvedDeliveryState = {
status: "not-delivered",
delivered: false,
failureNotification: { status: "not-requested" },
};
const finish = (deliveryAttempted: boolean) => ({
deliveryAttempted,
delivered: deliveryState.delivered,
deliveryError: deliveryState.error,
deliverySuppressionReason: deliveryState.deliverySuppressionReason,
deliveryState,
delivery: { ...delivery, delivered: deliveryState.delivered },
});
if (params.text === undefined) {
deliveryState.deliverySuppressionReason = params.suppressionReason ?? "empty";
return finish(false);
}
const { agentId, cfg } = params.resolveCronAgent(params.job.agentId);
const inspectUrl = resolveControlUiAutomationRunUrl(cfg, {
jobId: params.job.id,
runId:
params.runStartedAtMs === undefined
? undefined
: createCronExecutionId(params.job.id, params.runStartedAtMs),
});
// Command summaries are already redacted; adding the link earlier would strip its URL.
const text = inspectUrl ? `${params.text}\nInspect: ${inspectUrl}` : params.text;
const abortSignal = params.abortSignal ?? new AbortController().signal;
let deliveryMayHaveReachedRecipient = false;
try {
const result = await retryTransientDirectCronDelivery({
jobId: params.job.id,
label: params.label,
signal: abortSignal,
shouldRetryError: () => !deliveryMayHaveReachedRecipient,
run: () =>
sendCronAnnouncePayloadStrict({
deps: params.deps,
cfg,
agentId,
jobId: params.job.id,
target: {
channel: plan.channel,
to: plan.to,
threadId: plan.threadId,
accountId: plan.accountId,
sessionKey: resolveCronDeliverySessionKey(params.job),
},
payload: { text },
abortSignal,
...(params.runStartedAtMs === undefined
? {}
: { completion: { job: params.job, runStartedAt: params.runStartedAtMs } }),
onDeliveryAttempt: (reachedRecipient) => {
deliveryMayHaveReachedRecipient ||= reachedRecipient;
},
}),
});
if (result.status === "sent") {
deliveryState.status = "delivered";
deliveryState.delivered = true;
} else {
const uncertain = result.reason === "adapter_returned_no_identity";
deliveryState.status = uncertain ? "unknown" : "not-delivered";
deliveryState.delivered = uncertain ? undefined : false;
deliveryState.error = `cron delivery ${uncertain ? "outcome is unknown" : "was suppressed"}: ${result.reason}`;
}
return finish(true);
} catch (err) {
const deliveryError = formatErrorMessage(err);
params.logger.warn(
{ jobId: params.job.id, err: deliveryError },
`cron: ${params.label} delivery failed`,
);
deliveryState.error = deliveryError;
if (params.traceResolvedFailure) {
delivery.resolved = {
channel: plan.channel,
to: plan.to,
accountId: plan.accountId,
threadId: plan.threadId,
source: "explicit",
ok: false,
error: deliveryError,
};
}
return finish(true);
}
}
export function buildGatewayCronService(params: {
scheduler: GatewayScheduler;
cfg: OpenClawConfig;
@@ -706,7 +847,7 @@ export function buildGatewayCronService(params: {
}
}
},
runCommandJob: async ({ deliveryAttemptFence, job, abortSignal }) => {
runCommandJob: async ({ job, abortSignal }) => {
const result = await runCronCommandJob({
job,
abortSignal,
@@ -717,7 +858,6 @@ export function buildGatewayCronService(params: {
if (summaryIsSilent) {
const { summary: _summary, ...silentResult } = result;
const completion = await finalizeCronCompletionAnnouncement({
deliveryAttemptFence,
job,
suppressionReason: "silent",
deps: params.deps,
@@ -728,7 +868,6 @@ export function buildGatewayCronService(params: {
return { ...silentResult, ...completion };
}
const completion = await finalizeCronCompletionAnnouncement({
deliveryAttemptFence,
job,
text:
typeof result.summary === "string" && result.summary.trim()
@@ -744,24 +883,17 @@ export function buildGatewayCronService(params: {
});
return { ...result, ...completion };
},
sendCronWebhook: async ({ job, event, abortSignal, onDeliveryState, assertCurrent }) => {
sendCronWebhook: async ({ job, event, abortSignal, onDeliveryState }) => {
return await sendGatewayCronWebhook({
job,
event,
abortSignal,
assertCurrent,
onDeliveryState,
webhookToken: params.cfg.cron?.webhookToken,
ssrfPolicy: webhookSsrfPolicy,
});
},
runScriptJob: async ({
deliveryAttemptFence,
job,
streamBatch,
abortSignal,
executionIdentity,
}) => {
runScriptJob: async ({ job, streamBatch, abortSignal, executionIdentity }) => {
if (!scriptRuntime || job.payload.kind !== "script") {
return {
status: "error",
@@ -770,7 +902,6 @@ export function buildGatewayCronService(params: {
};
}
const execution = await scriptRuntime.executePayload({
deliveryAttemptFence,
job,
streamBatch,
abortSignal,
@@ -801,7 +932,6 @@ export function buildGatewayCronService(params: {
nextCheck: execution.nextCheck,
};
const completion = await finalizeCronCompletionAnnouncement({
deliveryAttemptFence,
job,
text: job.sessionTarget === "main" ? undefined : notify,
runStartedAtMs: job.state.runningAtMs,
@@ -75,9 +75,6 @@ export function createMessageActionRuntimeAuthority(
assertReadCurrent ??
assertScheduledWriteCurrent ??
params.authorization?.scheduled?.assertCurrent;
const assertDeliveryCurrent = !isFencedProviderReadAction(params.request.action)
? params.authorization?.deliveryAttempt?.assertCurrent
: undefined;
const scheduledPolicy =
assertReadCurrent || assertScheduledWriteCurrent
? params.authorization?.scheduled?.policy
@@ -86,11 +83,10 @@ export function createMessageActionRuntimeAuthority(
params.client,
params.context,
params.respond,
assertActionCurrent || assertDeliveryCurrent
assertActionCurrent
? () => {
params.sessionMutationCommitGuard?.();
assertActionCurrent?.();
assertDeliveryCurrent?.();
assertActionCurrent();
}
: params.sessionMutationCommitGuard,
);
@@ -100,17 +96,9 @@ export function createMessageActionRuntimeAuthority(
params.assertClientUploadAllowed?.();
}
: agentRuntimeAuthority.commitGuard;
const beforeDeliveryAttempt = () =>
isFencedProviderReadAction(params.request.action)
? undefined
: params.authorization?.deliveryAttempt?.beforeAttempt();
return {
assertReadCurrent,
assertScheduledWriteCurrent,
beforeDeliveryAttempt,
onPlatformSendDispatch: assertDirectAdapterHandoff
? async () => assertDirectAdapterHandoff()
: undefined,
routeAccountId:
normalizeOptionalString(params.request.accountId) ??
normalizeOptionalString(params.request.params.accountId) ??
+10 -10
View File
@@ -129,7 +129,9 @@ export const sendHandlers: GatewayRequestHandlers = {
assertClientUploadAllowed,
});
const assertDirectAdapterHandoff = messageAuthority.assertDirectAdapterHandoff;
const onPlatformSendDispatch = messageAuthority.onPlatformSendDispatch;
const onPlatformSendDispatch = assertDirectAdapterHandoff
? async () => assertDirectAdapterHandoff()
: undefined;
const downstreamToolContext = trustedContext.toolContext
? { ...trustedContext.toolContext, skipCrossContextDecoration: true as const }
: undefined;
@@ -422,8 +424,6 @@ export const sendHandlers: GatewayRequestHandlers = {
});
payload = result.payload;
} else {
await messageAuthority.beforeDeliveryAttempt();
assertDirectAdapterHandoff?.();
const handled = await dispatchChannelMessageAction(actionContext);
if (handled) {
payload = extractToolPayload(handled);
@@ -519,7 +519,9 @@ export const sendHandlers: GatewayRequestHandlers = {
const agentRuntimeAuthority = messageAuthority.agentRuntimeAuthority;
const hasAgentRuntimeAuthority = client?.internal?.agentRuntimeIdentity !== undefined;
const commitAgentRuntimeAuthority = messageAuthority.assertDirectAdapterHandoff;
const onPlatformSendDispatch = messageAuthority.onPlatformSendDispatch;
const onPlatformSendDispatch = commitAgentRuntimeAuthority
? async () => commitAgentRuntimeAuthority()
: undefined;
await withMessageOperationRoute({
context,
prefix: "send",
@@ -733,8 +735,6 @@ export const sendHandlers: GatewayRequestHandlers = {
if (!authorize()) {
return createGatewayInflightAuthorityFailure({ context, dedupeKey, channel });
}
await messageAuthority.beforeDeliveryAttempt();
commitAgentRuntimeAuthority?.();
const send = await sendDurableMessageBatchCore(
{
cfg,
@@ -834,8 +834,10 @@ export const sendHandlers: GatewayRequestHandlers = {
const messageActionConfig = resolveAgentRuntimeMessageActionConfig(client);
const agentRuntimeAuthority = messageAuthority.agentRuntimeAuthority;
const hasAgentRuntimeAuthority = client?.internal?.agentRuntimeIdentity !== undefined;
const commitAgentRuntimeAuthority = messageAuthority.assertDirectAdapterHandoff;
const onPlatformSendDispatch = messageAuthority.onPlatformSendDispatch;
const commitAgentRuntimeAuthority = agentRuntimeAuthority.commitGuard;
const onPlatformSendDispatch = commitAgentRuntimeAuthority
? async () => commitAgentRuntimeAuthority()
: undefined;
await withMessageOperationRoute({
context,
prefix: "poll",
@@ -921,8 +923,6 @@ export const sendHandlers: GatewayRequestHandlers = {
if (!authorize()) {
return createGatewayInflightAuthorityFailure({ context, dedupeKey, channel });
}
await messageAuthority.beforeDeliveryAttempt();
commitAgentRuntimeAuthority?.();
const result = await sendPoll({
cfg,
to: resolvedTarget.to,
@@ -137,7 +137,6 @@ async function withHostedCreation(
sessionId: "scope-parent-session",
sessionKey: parentKey,
jobId: "scope-test-job",
deliveryAttemptFence: null,
})
: undefined;
const parent =
-1
View File
@@ -543,7 +543,6 @@ export function createGatewayHookDispatcher(params: {
cfg,
deps,
job,
deliveryAttemptFence: null,
message: acceptedValue.message,
sessionKey,
// Isolated runs derive their lifecycle key from random jobId (or an
@@ -73,17 +73,6 @@ export function assertMessageDeliveryCurrent(input: MessageActionInput): void {
throwIfAborted(input.abortSignal);
input.assertDirectAdapterHandoff?.();
input.messageActionAuthorization?.scheduled?.assertCurrent();
input.messageActionAuthorization?.deliveryAttempt?.assertCurrent();
}
export async function beforeMessageDeliveryAttempt(input: MessageActionInput): Promise<void> {
const deliveryAttempt = input.messageActionAuthorization?.deliveryAttempt;
if (!deliveryAttempt) {
return;
}
assertMessageDeliveryCurrent(input);
await deliveryAttempt.beforeAttempt();
assertMessageDeliveryCurrent(input);
}
async function callGatewayMessageAction<T>(params: {
+12 -20
View File
@@ -43,7 +43,6 @@ import {
import { MessageActionDeniedError } from "./message-action-denial.js";
import {
assertMessageDeliveryCurrent,
beforeMessageDeliveryAttempt,
executeMessagePlugin,
executeMessagePoll,
} from "./message-action-execution.js";
@@ -427,9 +426,6 @@ async function handleInternalSourceReplySendAction(
const idempotencyKey = normalizeOptionalString(params.idempotencyKey);
let persistedIdempotencyKey: string | undefined;
let persistedTranscriptOwner = false;
if (!dryRun) {
await beforeMessageDeliveryAttempt(input);
}
if (!dryRun && input.sessionId) {
const sessionKey = input.sourceReplySessionKey ?? input.sessionKey;
if (!sessionKey) {
@@ -449,10 +445,8 @@ async function handleInternalSourceReplySendAction(
toolCallId: input.sourceReplyToolCallId,
sourceTurnId: input.messageActionAuthorization?.toolContext?.currentSourceTurnId,
});
if (
input.messageActionAuthorization?.scheduled ||
input.messageActionAuthorization?.deliveryAttempt
) {
const scheduled = input.messageActionAuthorization?.scheduled;
if (scheduled) {
await withSessionTranscriptWriteAssertion(
{
agentId: input.agentId ?? resolveSessionAgentId({ sessionKey, config: input.cfg }),
@@ -695,18 +689,16 @@ export async function runMessageAction(input: MessageActionInput): Promise<Messa
},
input.abortSignal,
);
if (!context.dryRun && !isFencedProviderReadAction(action)) {
await beforeMessageDeliveryAttempt(context.input);
if (
context.input.messageActionAuthorization?.scheduled ||
context.input.messageActionAuthorization?.deliveryAttempt
) {
const deliveryInput = context.input;
context.input = {
...deliveryInput,
assertDirectAdapterHandoff: () => assertMessageDeliveryCurrent(deliveryInput),
};
}
if (
!context.dryRun &&
!isFencedProviderReadAction(action) &&
context.input.messageActionAuthorization?.scheduled
) {
const deliveryInput = context.input;
context.input = {
...deliveryInput,
assertDirectAdapterHandoff: () => assertMessageDeliveryCurrent(deliveryInput),
};
}
if (action === "send") {
return executeMessageSend(context);
-21
View File
@@ -1,21 +0,0 @@
type ImmutableActivationOptions = {
root: string;
onReceipt?: (line: string) => void;
};
function unsupported(options: ImmutableActivationOptions): never {
options.onReceipt?.("immutable:stable-downgrade-refused");
throw new Error(
"Immutable activation belongs to a newer updater. The selected generation was not changed; recover it with the version that adopted this installation.",
);
}
/** Never adopt or mutate a newer immutable activation record from this stable line. */
export async function activateImmutableUpdate(options: ImmutableActivationOptions): Promise<never> {
return unsupported(options);
}
/** Never adopt or mutate a newer immutable recovery record from this stable line. */
export async function recoverImmutableUpdate(options: ImmutableActivationOptions): Promise<never> {
return unsupported(options);
}
@@ -1,2 +0,0 @@
/** Preserve the beta updater's split module while the stable owner remains monolithic. */
export { isCurrentManagedServiceUpdateHandoffProcess } from "./update-managed-service-handoff.js";
@@ -1,16 +0,0 @@
type RecoveryBaselineCaptureOptions = {
assertCurrent: () => void;
};
/**
* A beta updater treats original-state capture failure as a warning. Refuse
* before writing a partial capture rather than fabricating recovery evidence.
*/
export async function captureUpdateRecoveryBaseline(
options: RecoveryBaselineCaptureOptions,
): Promise<never> {
options.assertCurrent();
throw new Error(
"Original-state capture is unavailable after switching to this older stable updater.",
);
}
-30
View File
@@ -1,30 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { closeDefaultRetainedNativeWorkerSource } from "./worker-native-lifecycle.js";
const NATIVE_WORKER_LIFETIMES = Symbol.for("openclaw.nativeWorkerLifetimes");
const globalStore = globalThis as Record<PropertyKey, unknown>;
afterEach(() => {
delete globalStore[NATIVE_WORKER_LIFETIMES];
});
describe("October beta native worker compatibility", () => {
it("joins an existing default source", async () => {
const close = vi.fn().mockResolvedValue(undefined);
globalStore[NATIVE_WORKER_LIFETIMES] = {
nextId: 1,
sources: new WeakMap(),
defaultSource: { close },
};
await closeDefaultRetainedNativeWorkerSource();
expect(close).toHaveBeenCalledOnce();
});
it("does not initialize a missing owner", async () => {
await closeDefaultRetainedNativeWorkerSource();
expect(Object.hasOwn(globalStore, NATIVE_WORKER_LIFETIMES)).toBe(false);
});
});
-14
View File
@@ -1,14 +0,0 @@
import { readGlobalSingleton } from "../shared/global-singleton.js";
type NativeWorkerLifetime = {
defaultSource?: { close(): Promise<void> };
};
const NATIVE_WORKER_LIFETIMES = Symbol.for("openclaw.nativeWorkerLifetimes");
/** Join an October-beta native owner when downgrading into this stable release. */
export async function closeDefaultRetainedNativeWorkerSource(): Promise<void> {
// SAFETY: The October beta owns this symbol and records a close-capable default source.
const lifetime = readGlobalSingleton(NATIVE_WORKER_LIFETIMES) as NativeWorkerLifetime | undefined;
await lifetime?.defaultSource?.close();
}
@@ -54,8 +54,6 @@ export async function commitBackgroundResultToSession(params: {
provenance: BackgroundSessionResultProvenance;
config: OpenClawConfig;
signal?: AbortSignal;
/** Revalidate the producer after preparation and inside the transcript commit. */
assertCurrent?: () => void;
}): Promise<BackgroundSessionResultCommit> {
const sessionKey = normalizeOptionalString(params.sessionKey);
const text = normalizeOptionalString(params.text);
@@ -76,7 +74,6 @@ export async function commitBackgroundResultToSession(params: {
);
const identities = [sessionKey, expectedSessionId];
params.assertCurrent?.();
return await runExclusiveSessionLifecycleMutation({
scope: storePath,
identities,
@@ -88,7 +85,6 @@ export async function commitBackgroundResultToSession(params: {
}
},
run: async () => {
params.assertCurrent?.();
const current = loadSessionEntryReadOnly({
agentId: params.agentId,
sessionKey,
@@ -141,7 +137,6 @@ export async function commitBackgroundResultToSession(params: {
idempotencyKey: string;
openclawAutomation: BackgroundSessionResultProvenance;
};
params.assertCurrent?.();
const committed = await persistSessionTranscriptTurn(scope, {
cwd: current.spawnedCwd,
expectedSessionId,
@@ -154,7 +149,6 @@ export async function commitBackgroundResultToSession(params: {
idempotencyLookup: "scan",
...(priorId ? { eventId: priorId } : {}),
shouldAppendInTransaction: () => {
params.assertCurrent?.();
params.signal?.throwIfAborted();
if (priorId && !readActiveTranscriptEntryAnchor({ ...scope, entryId: priorId })) {
throw new Error("background result no longer owns the active transcript");
+1 -2
View File
@@ -4,7 +4,6 @@ import type { DatabasePathIdentity } from "../infra/sqlite-worker-identity.js";
export type OpenClawStateSchemaReadAdmission = (database: DatabaseSync) => (() => void) | undefined;
// v20 fences possibly delivered cron completions across restart recovery.
// v19 preserves original channel-owner authorization across recovery.
// v18 binds shared GitHub publication to its original requesting authority.
// v17 records one-use prepared worker capacity and node workspace ownership.
@@ -20,7 +19,7 @@ export type OpenClawStateSchemaReadAdmission = (database: DatabaseSync) => (() =
// v7 retires the inert shared commitments table.
// v6 makes every committed shared-state table part of the canonical runtime schema.
// v5 records durable cloud-worker result refs on pending workspace fences.
export const OPENCLAW_STATE_SCHEMA_VERSION = 20;
export const OPENCLAW_STATE_SCHEMA_VERSION = 19;
export const OPENCLAW_STATE_STRICT_SCHEMA_VERSION = 3;
// Absence records lost history; only Doctor may reconstruct these on existing state.
export const DOCTOR_OWNED_STATE_TABLES = ["agent_deletion_journal"] as const;
@@ -1,92 +0,0 @@
import { DatabaseSync } from "node:sqlite";
import { afterEach, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { ensureCronRunReceiptSchema } from "../cron/store/run-receipt-store.js";
import { preflightOpenClawDatabaseSchemas } from "./openclaw-database-preflight.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
repairOpenClawStateDatabaseSchema,
} from "./openclaw-state-db.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(() => closeOpenClawStateDatabaseForTest());
function legacyReceiptDatabase() {
const options = { env: { OPENCLAW_STATE_DIR: tempDirs.make("cron-delivery-migration-") } };
const database = openOpenClawStateDatabase(options);
ensureCronRunReceiptSchema(database.db);
const databasePath = database.path;
closeOpenClawStateDatabaseForTest();
const legacy = new DatabaseSync(databasePath);
legacy.exec(`
ALTER TABLE cron_run_receipts DROP COLUMN delivery_attempt_state;
INSERT INTO cron_run_receipts (
receipt_id, store_key, job_id, config_revision, agent_id, status,
owner_pid, owner_start_time, started_at_ms
) VALUES ('legacy-receipt', '/fixture/cron', 'legacy-job', 'revision', 'main', 'running', 123, 1, 2);
PRAGMA user_version = 19;
UPDATE schema_meta SET schema_version = 19;
`);
legacy.close();
return { options, databasePath };
}
it.each(["runtime open", "doctor repair"] as const)(
"%s preserves legacy receipt uncertainty and refuses a schema-19 downgrade",
async (entry) => {
const { options } = legacyReceiptDatabase();
if (entry === "doctor repair") {
expect(repairOpenClawStateDatabaseSchema(options).warnings).toEqual([]);
}
const { db } = openOpenClawStateDatabase(options);
expect(
db.prepare("SELECT receipt_id, status, delivery_attempt_state FROM cron_run_receipts").all(),
).toEqual([
{ receipt_id: "legacy-receipt", status: "running", delivery_attempt_state: "unknown" },
]);
expect(db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 20 });
expect(db.prepare("PRAGMA integrity_check").get()).toEqual({ integrity_check: "ok" });
db.exec("UPDATE cron_run_receipts SET delivery_attempt_state = 'started'");
closeOpenClawStateDatabaseForTest();
expect(
openOpenClawStateDatabase(options)
.db.prepare("SELECT delivery_attempt_state FROM cron_run_receipts")
.get(),
).toEqual({ delivery_attempt_state: "started" });
closeOpenClawStateDatabaseForTest();
const preflight = await preflightOpenClawDatabaseSchemas({
env: options.env,
scope: "state",
supportedVersions: { state: 19, agent: 23 },
});
expect(preflight.incompatible).toEqual([
expect.objectContaining({ kind: "state", foundVersion: 20, supportedVersion: 19 }),
]);
},
);
it("rolls receipt migration back with schema publication failure", () => {
const { options, databasePath } = legacyReceiptDatabase();
const legacy = new DatabaseSync(databasePath);
legacy.exec(`CREATE TRIGGER refuse_schema_publication BEFORE UPDATE ON schema_meta
BEGIN SELECT RAISE(ABORT, 'fixture publication refusal'); END;`);
legacy.close();
expect(() => openOpenClawStateDatabase(options)).toThrow("fixture publication refusal");
const after = new DatabaseSync(databasePath, { readOnly: true });
try {
expect(after.prepare("PRAGMA user_version").get()).toEqual({ user_version: 19 });
expect(after.prepare("SELECT receipt_id, status FROM cron_run_receipts").all()).toEqual([
{ receipt_id: "legacy-receipt", status: "running" },
]);
expect(
after
.prepare(
"SELECT 1 FROM pragma_table_info('cron_run_receipts') WHERE name = 'delivery_attempt_state'",
)
.get(),
).toBeUndefined();
} finally {
after.close();
}
});
@@ -1,16 +0,0 @@
import type { DatabaseSync } from "node:sqlite";
import { ensureColumn, tableExists } from "./openclaw-state-db-schema-helpers.js";
/** Absence of old delivery evidence cannot prove that a completion was never sent. */
export function migrateCronDeliveryAttemptState(
db: DatabaseSync,
previousVersion: number,
): boolean {
return previousVersion < 20 && tableExists(db, "cron_run_receipts")
? ensureColumn(
db,
"cron_run_receipts",
"delivery_attempt_state TEXT NOT NULL DEFAULT 'unknown' CHECK (delivery_attempt_state IN ('unknown', 'not-started', 'started'))",
)
: false;
}
+1 -8
View File
@@ -21,7 +21,6 @@ import {
OPENCLAW_STATE_SCHEMA_VERSION,
type OpenClawStateDatabaseOptions,
} from "./openclaw-state-db-contract.js";
import { migrateCronDeliveryAttemptState } from "./openclaw-state-db-cron-delivery-migration.js";
import {
hasDanglingSkillWorkshopCollectionReviewIndex,
LEGACY_SKILL_WORKSHOP_COLLECTION_REVIEWS_INDEX,
@@ -172,8 +171,6 @@ const STATE_MIGRATION_ALLOWED_MISSING_TABLES = {
15: LAZY_ADDITIVE_STATE_TABLES,
16: LAZY_ADDITIVE_STATE_TABLES,
17: LAZY_ADDITIVE_STATE_TABLES,
18: LAZY_ADDITIVE_STATE_TABLES,
19: LAZY_ADDITIVE_STATE_TABLES,
} as const satisfies Record<number, readonly string[]>;
type OpenClawStateMigrationVersion = keyof typeof STATE_MIGRATION_ALLOWED_MISSING_TABLES;
@@ -272,7 +269,7 @@ export const openClawStateMigrationAssertions = new Map<
number,
(database: DatabaseSync, options: { pathname: string }) => void
>(
([5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19] as const).map(
([5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17] as const).map(
(version) =>
[
version,
@@ -566,10 +563,6 @@ export const versionedStateMigrations: ReadonlyArray<{
migrate: migrateGitHubPublicationRequesterAuthority,
applied: "Added original requester authority to GitHub publication receipts (v18)",
},
{
migrate: migrateCronDeliveryAttemptState,
applied: "Recorded cron completion delivery attempt uncertainty (v20)",
},
];
export function runStateSchemaMigrationTransaction<T>(
-1
View File
@@ -391,7 +391,6 @@ export interface CronJobs {
export interface CronRunReceipts {
agent_id: string;
config_revision: string;
delivery_attempt_state: Generated<string>;
error_text: string | null;
finished_at_ms: number | null;
job_id: string;
@@ -1,10 +0,0 @@
/** Preserve the beta updater's worker-operation module after package replacement. */
export async function prepareOpenClawStateLeaseWorkerRuntime(): Promise<void> {
await Promise.all([
import("./openclaw-state-worker-store.js"),
import("../infra/sqlite-worker-identity.js"),
import("../infra/sqlite-worker-store.js"),
]);
}
export { runWithOpenClawStateLeaseWorker } from "./openclaw-state-lease-worker-storage.js";
-1
View File
@@ -1490,7 +1490,6 @@ CREATE TABLE IF NOT EXISTS cron_run_receipts (
config_revision TEXT NOT NULL,
agent_id TEXT NOT NULL,
request_run_id TEXT,
delivery_attempt_state TEXT NOT NULL DEFAULT 'unknown' CHECK (delivery_attempt_state IN ('unknown', 'not-started', 'started')),
status TEXT NOT NULL,
owner_pid INTEGER NOT NULL,
owner_start_time INTEGER,
@@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
@@ -135,6 +136,36 @@ describe("Periphery intersection", () => {
describe("shared OpenClawKit Periphery workflow", () => {
const workflow = parse(readFileSync(WORKFLOW_PATH, "utf8")) as Workflow;
it.runIf(process.platform !== "win32").each(["scan-ios", "scan-macos"])(
"fails %s when the scanner crashes while preserving its status artifact",
(jobName) => {
const root = mkdtempSync(join(tmpdir(), "openclaw-periphery-producer-"));
const platform = jobName === "scan-ios" ? "ios" : "macos";
const scan = workflow.jobs?.[jobName]?.steps?.find((step) => step.name === "Scan shared kit");
try {
mkdirSync(join(root, "apps", platform), { recursive: true });
mkdirSync(join(root, `shared-periphery-${platform}`));
mkdirSync(join(root, "bin"));
writeFileSync(join(root, "bin", "periphery"), "#!/bin/sh\nexit 138\n", { mode: 0o755 });
const result = spawnSync("bash", ["-c", scan?.run ?? ""], {
cwd: root,
encoding: "utf8",
env: {
...process.env,
RUNNER_TEMP: root,
PATH: `${join(root, "bin")}:${process.env.PATH}`,
},
});
expect(result.status, result.stderr).toBe(138);
expect(
readFileSync(join(root, `shared-periphery-${platform}`, "periphery.status"), "utf8"),
).toBe("138\n");
} finally {
rmSync(root, { force: true, recursive: true });
}
},
);
it("runs two consumer scans and a same-run intersection", () => {
expect(workflow.jobs?.["scan-ios"]?.name).toBe("Scan shared kit from iOS");
expect(workflow.jobs?.["scan-macos"]?.name).toBe("Scan shared kit from macOS");
+41
View File
@@ -1803,6 +1803,47 @@ describe("previous release update compatibility", () => {
},
);
it("keeps stable upgrade coverage independent of a newer beta tag", () => {
const output = writeWindowInventory(createTempDir("update-compat-newer-beta-"));
const npmArgs = ["view", "openclaw", "dist-tags", "--json"];
const { result, calls } = runInventoryCli(
["--check", "--output", output],
[{ args: npmArgs, value: { latest: "2026.9.3", beta: "2026.10.1-beta.2" } }],
);
expect(result.status, result.stderr).toBe(0);
expect(calls).toEqual([npmArgs]);
});
it.each(["generate", "check"])("refuses newer-release downgrade inventory during %s", (mode) => {
const root = createTempDir("update-compat-newer-source-");
const output = writeWindowInventory(root);
const original = fsSync.readFileSync(output, "utf8");
const version = "2026.10.1-beta.2";
let args: string[];
if (mode === "generate") {
const packageDir = path.join(root, version);
write(packageDir, "package.json", JSON.stringify({ name: "openclaw", version }));
write(
packageDir,
"dist/build-info.json",
JSON.stringify({ version, buildId: version, commit: "0".repeat(40) }),
);
write(packageDir, "dist/entry.js", "export {};\n");
args = ["--output", output, "--release", `${packageDir}=${integrity}`];
} else {
const inventory = JSON.parse(original);
inventory.releases.push({ ...inventory.releases[0], version, chunks: [] });
write(root, path.basename(output), JSON.stringify(inventory));
args = ["--check", "--output", output];
}
const before = fsSync.readFileSync(output, "utf8");
const { result, calls } = runInventoryCli(args);
expect(result.status).toBe(1);
expect(result.stderr).toContain("source 2026.10.1-beta.2 is newer than target 2026.9.9");
expect(fsSync.readFileSync(output, "utf8")).toBe(before);
expect(calls).toEqual([]);
});
it.each([
{ latest: "2026.9.3", beta: "2026.9.4-beta.1", missing: "2026.9.4-beta.1" },
{ latest: "2026.9.4", beta: "2026.9.4", missing: "2026.9.4" },
-10
View File
@@ -447,16 +447,6 @@ function buildCoreDistEntries(): Record<string, string> {
entry: "src/entry.ts",
"infra/package-lifecycle": "src/infra/package-lifecycle.ts",
"commands/doctor-update-schema-guard": "src/commands/doctor-update-schema-guard.ts",
// Retain the current owner behind the beta updater's recorded post-swap import.
"daemon/launchd-update-compat": "src/daemon/launchd-update-compat.ts",
"state/openclaw-state-lease-worker-operation":
"src/state/openclaw-state-lease-worker-operation.ts",
"plugins/plugin-lifecycle-lease": "src/plugins/plugin-lifecycle-lease.ts",
"infra/update-managed-service-handoff-current":
"src/infra/update-managed-service-handoff-current.ts",
"cli/update-cli/update-command-immutable": "src/cli/update-cli/update-command-immutable.ts",
"infra/update-immutable-activation": "src/infra/update-immutable-activation.ts",
"infra/update-recovery-baseline-capture": "src/infra/update-recovery-baseline-capture.ts",
"crabbox-wrapper": "scripts/crabbox-wrapper.mts",
"docker-healthcheck": "src/docker-healthcheck.ts",
// Ensure this module is bundled as an entry so legacy CLI shims can resolve its exports.