mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
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:
committed by
GitHub
parent
9a0ea5d69b
commit
29ab80f185
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user