fix(update): record verified install receipts even when the handoff fails (#121664)

A campaign apply that succeeds at the git level but fails its managed-service
handoff (e.g. a post-restart health-probe false negative under SQLite load)
marked the update sentinel "error", so the #121328 receipt-backed upstream
fallback never engaged and dev campaigns stalled silently on "no-upstream"
until manual branch re-attach. The receipt now records the install fact
proven by the running process (git mode, root + revision verified, install
changed) regardless of sentinel status; the reader and store drop their
redundant success gates; the concept is renamed Successful* -> Verified*.
Receipt writes are scoped to git mode (the only mode ever read), so dead
npm-mode rows can no longer clobber a valid git receipt.

Fixes #121634
This commit is contained in:
Peter Steinberger
2026-08-10 13:27:10 -07:00
committed by GitHub
parent 9a0ea5d69b
commit 29ab80f185
5 changed files with 65 additions and 31 deletions
+2 -2
View File
@@ -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 =
+36 -11
View File
@@ -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();
});
});
});
+12 -8
View File
@@ -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<SuccessfulGitUpdateReceipt | null> {
return normalizeSuccessfulGitUpdateReceipt(await readUpdateInstallReceiptPayload(env));
): Promise<VerifiedGitUpdateReceipt | null> {
return normalizeVerifiedGitUpdateReceipt(await readUpdateInstallReceiptPayload(env));
}
export async function hasRestartSentinel(env: NodeJS.ProcessEnv = process.env): Promise<boolean> {
+10 -2
View File
@@ -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",
+5 -8
View File
@@ -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<UpdateCheckResult["git"]>,
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;