diff --git a/src/infra/restart-sentinel-store.ts b/src/infra/restart-sentinel-store.ts index 2c3843e728c5..0a3881ee49f3 100644 --- a/src/infra/restart-sentinel-store.ts +++ b/src/infra/restart-sentinel-store.ts @@ -612,8 +612,8 @@ export function writeUpdateInstallReceiptRowSync( rawPayload: RestartSentinelPayload, ): RestartSentinel { const payload = requireValidPayload(rawPayload); - if (payload.kind !== "update" || payload.status !== "ok") { - throw new TypeError("Update install receipt requires a successful update payload"); + if (payload.kind !== "update" || payload.stats?.mode !== "git") { + throw new TypeError("Update install receipt requires a git update payload"); } const current = readRestartSentinelRowForKeySync(db, UPDATE_INSTALL_RECEIPT_KEY); const currentRevision = diff --git a/src/infra/restart-sentinel.test.ts b/src/infra/restart-sentinel.test.ts index a78d7328724c..5dbed929be0a 100644 --- a/src/infra/restart-sentinel.test.ts +++ b/src/infra/restart-sentinel.test.ts @@ -52,7 +52,7 @@ import { hasRestartSentinel, markUpdateRestartSentinelFailure, readRestartSentinel, - readSuccessfulGitUpdateReceipt, + readVerifiedGitUpdateReceipt, summarizeRestartSentinel, trimLogTail, writeRestartSentinel, @@ -541,7 +541,14 @@ describe("restart sentinel", () => { }); }); - it("persists the verified Git install receipt after restart", async () => { + it.each([ + { name: "successful update", status: "ok", reason: undefined }, + { + name: "failed handoff", + status: "error", + reason: "managed-service-handoff-failed", + }, + ] as const)("persists the verified Git install receipt after a $name", async (testCase) => { await withRestartSentinelStateDir(async () => { await withTempDir({ prefix: "openclaw-install-root-" }, async (tempDir) => { const installRoot = path.join(tempDir, "checkout"); @@ -551,10 +558,11 @@ describe("restart sentinel", () => { const ts = Date.now(); await writeRestartSentinel({ kind: "update", - status: "ok", + status: testCase.status, ts, stats: { mode: "git", + ...(testCase.reason ? { reason: testCase.reason } : {}), root: installAlias, before: { sha: "aaaaaaaa" }, after: { @@ -573,7 +581,7 @@ describe("restart sentinel", () => { ); await clearRestartSentinel(); - await expect(readSuccessfulGitUpdateReceipt()).resolves.toEqual({ + await expect(readVerifiedGitUpdateReceipt()).resolves.toEqual({ root: await fs.realpath(installRoot), sha: "bbbbbbbb", upstreamRef: "origin/main", @@ -604,19 +612,36 @@ describe("restart sentinel", () => { process.cwd(), ); - await expect(readSuccessfulGitUpdateReceipt()).resolves.toBeNull(); + await expect(readVerifiedGitUpdateReceipt()).resolves.toBeNull(); }); }); - it("rejects a restarted Git revision that does not match the update result", async () => { + it.each([ + { + name: "successful update", + status: "ok", + runningCommit: "cccccccc", + beforeSha: undefined, + expectedReason: "restart-revision-mismatch", + }, + { + name: "error-status update", + status: "error", + runningCommit: "aaaaaaaa", + beforeSha: "aaaaaaaa", + expectedReason: "managed-service-handoff-failed", + }, + ] as const)("rejects a $name whose running Git revision does not match", async (testCase) => { await withRestartSentinelStateDir(async () => { await writeRestartSentinel({ kind: "update", - status: "ok", + status: testCase.status, ts: Date.now(), stats: { mode: "git", root: process.cwd(), + ...(testCase.beforeSha ? { before: { sha: testCase.beforeSha } } : {}), + ...(testCase.status === "error" ? { reason: "managed-service-handoff-failed" } : {}), after: { sha: "bbbbbbbb", version: "expected-version" }, }, }); @@ -624,17 +649,17 @@ describe("restart sentinel", () => { await finalizeUpdateRestartSentinelRunningVersion( "actual-version", process.env, - "cccccccc", + testCase.runningCommit, process.cwd(), ); await expect(readRestartSentinel()).resolves.toMatchObject({ payload: { status: "error", - stats: { reason: "restart-revision-mismatch" }, + stats: { reason: testCase.expectedReason }, }, }); - await expect(readSuccessfulGitUpdateReceipt()).resolves.toBeNull(); + await expect(readVerifiedGitUpdateReceipt()).resolves.toBeNull(); }); }); @@ -670,7 +695,7 @@ describe("restart sentinel", () => { stats: { reason: "restart-root-mismatch" }, }, }); - await expect(readSuccessfulGitUpdateReceipt()).resolves.toBeNull(); + await expect(readVerifiedGitUpdateReceipt()).resolves.toBeNull(); }); }); }); diff --git a/src/infra/restart-sentinel.ts b/src/infra/restart-sentinel.ts index 9d48ea466948..451895f855e7 100644 --- a/src/infra/restart-sentinel.ts +++ b/src/infra/restart-sentinel.ts @@ -29,7 +29,7 @@ export type { RestartSentinelPayload, } from "./restart-sentinel-store.js"; -export type SuccessfulGitUpdateReceipt = { +export type VerifiedGitUpdateReceipt = { root: string; sha: string; upstreamRef?: string; @@ -175,7 +175,10 @@ export async function finalizeUpdateRestartSentinelRunningVersion( if (!finalized) { return null; } - if (payload.status === "ok" && verifiesInstallRoot && verifiesGitRevision && changedInstall) { + // This receipt records the install fact proven by the running process. Post-install + // failures such as managed-service-handoff-failed keep the sentinel in error without + // erasing the upstream fallback for campaign-managed detached installs (#121634). + if (stats.mode === "git" && verifiesInstallRoot && verifiesGitRevision && changedInstall) { writeUpdateInstallReceiptRowSync(db, payload); } return changed ? finalized : null; @@ -264,12 +267,13 @@ async function readUpdateInstallReceiptPayload( } } -function normalizeSuccessfulGitUpdateReceipt( +function normalizeVerifiedGitUpdateReceipt( payload: RestartSentinelPayload | null, -): SuccessfulGitUpdateReceipt | null { +): VerifiedGitUpdateReceipt | null { + // Receipt rows are only written after the running install verifies root and revision. + // An error status records a post-install failure, not an untrusted install. if ( payload?.kind !== "update" || - payload.status !== "ok" || payload.stats?.mode !== "git" || !isPlainRecord(payload.stats.after) ) { @@ -292,10 +296,10 @@ function normalizeSuccessfulGitUpdateReceipt( }; } -export async function readSuccessfulGitUpdateReceipt( +export async function readVerifiedGitUpdateReceipt( env: NodeJS.ProcessEnv = process.env, -): Promise { - return normalizeSuccessfulGitUpdateReceipt(await readUpdateInstallReceiptPayload(env)); +): Promise { + return normalizeVerifiedGitUpdateReceipt(await readUpdateInstallReceiptPayload(env)); } export async function hasRestartSentinel(env: NodeJS.ProcessEnv = process.env): Promise { diff --git a/src/infra/update-startup.test.ts b/src/infra/update-startup.test.ts index 3adb25a233d0..28faa198cfc7 100644 --- a/src/infra/update-startup.test.ts +++ b/src/infra/update-startup.test.ts @@ -1214,14 +1214,22 @@ describe("update-startup", () => { }); }); - it("continues automatic dev campaigns from receipt-backed detached HEAD", async () => { + it.each([ + { name: "successful install", status: "ok", reason: undefined }, + { + name: "failed handoff", + status: "error", + reason: "managed-service-handoff-failed", + }, + ] as const)("continues automatic dev campaigns from a $name receipt", async (testCase) => { runOpenClawStateWriteTransaction(({ db }) => { writeUpdateInstallReceiptRowSync(db, { kind: "update", - status: "ok", + status: testCase.status, ts: Date.now() - 60_000, stats: { mode: "git", + ...(testCase.reason ? { reason: testCase.reason } : {}), root: "/opt/openclaw", after: { sha: "current-sha", diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index 62c15f880126..6cf38fe5f0df 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -36,10 +36,7 @@ import { getNodeSqliteKysely, } from "./kysely-sync.js"; import { resolveOpenClawPackageRoot } from "./openclaw-root.js"; -import { - readSuccessfulGitUpdateReceipt, - type SuccessfulGitUpdateReceipt, -} from "./restart-sentinel.js"; +import { readVerifiedGitUpdateReceipt, type VerifiedGitUpdateReceipt } from "./restart-sentinel.js"; import { resolveGatewayRestartDeferralTimeoutMs, scheduleGatewaySigusr1Restart, @@ -577,7 +574,7 @@ async function resolveStartupInstallStatus(fetchGit: boolean) { argv1: process.argv[1], cwd: process.cwd(), }), - readSuccessfulGitUpdateReceipt(), + readVerifiedGitUpdateReceipt(), ]); const gitUpstreamFallback = installReceipt?.upstreamRef && root && updateInstallRootsMatch(root, installReceipt.root) @@ -607,7 +604,7 @@ function gitCommitsMatch(left: string, right: string): boolean { function resolveGitInstalledAtMs( git: NonNullable, - installReceipt: SuccessfulGitUpdateReceipt | null, + installReceipt: VerifiedGitUpdateReceipt | null, root: string | null, ): number | undefined { return installReceipt && @@ -621,7 +618,7 @@ function resolveGitInstalledAtMs( function resolveGitScheduleStatus( update: UpdateCheckResult, - installReceipt: SuccessfulGitUpdateReceipt | null, + installReceipt: VerifiedGitUpdateReceipt | null, root: string | null, ): GitScheduleStatus | undefined { if (update.installKind !== "git") { @@ -672,7 +669,7 @@ function withInstallStatus( schedule: UpdateScheduleState, update: UpdateCheckResult, includeGitStatus: boolean, - installReceipt: SuccessfulGitUpdateReceipt | null, + installReceipt: VerifiedGitUpdateReceipt | null, root: string | null, ): UpdateScheduleState { const git = includeGitStatus ? resolveGitScheduleStatus(update, installReceipt, root) : undefined;