mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-26 10:13:27 +08:00
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:
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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -372,7 +372,6 @@ describe("cron service timer seam coverage", () => {
|
||||
expect(runCommandJob).toHaveBeenCalledWith({
|
||||
job,
|
||||
abortSignal: undefined,
|
||||
deliveryAttemptFence: null,
|
||||
});
|
||||
expect(runIsolatedAgentJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -54,10 +54,6 @@ export type CronRuntimeMutationInputs = {
|
||||
terminal?: CronReceiptTerminal;
|
||||
requireCurrentReceipt?: boolean;
|
||||
};
|
||||
"cron.markDeliveryStarted": {
|
||||
storeKey: string;
|
||||
handle: CronRunReceiptHandle;
|
||||
};
|
||||
"cron.finishReceipt": {
|
||||
storeKey: string;
|
||||
terminal: CronReceiptTerminal;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -656,7 +656,6 @@ describe("buildGatewayCronService", () => {
|
||||
|
||||
try {
|
||||
await getCronDeps(state).runIsolatedAgentJob({
|
||||
deliveryAttemptFence: null,
|
||||
job,
|
||||
message: "review",
|
||||
abortSignal: abortController.signal,
|
||||
|
||||
+147
-17
@@ -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) ??
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user