Skip to content

Commit 2ed2424

Browse files
authored
feat: harden live gateway and mac restarts (#104314)
* feat: audit gateway logs after live restarts * fix: keep mac app running during replacement build
1 parent b01affe commit 2ed2424

5 files changed

Lines changed: 176 additions & 13 deletions

File tree

.agents/skills/openclaw-live-updater/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Keep `/Users/steipete/openclaw` a read-only-to-the-agent deployment mirror: clea
3232
- A dependency-input change, absent `node_modules`, or missing/invalid build provenance first runs `pnpm install --frozen-lockfile`.
3333
- `pnpm build` must leave both canonical stamp heads and `dist/build-info.json.commit` equal to post-update `afterSha`; any missing/mismatched stamp or required artifact blocks restart.
3434
- Only after exact-SHA build proof may it restart the managed Gateway and require `gateway status --deep --require-rpc --json` plus `health --verbose --json`.
35+
- After every managed restart, query Gateway logs through RPC, restrict the audit to entries emitted since that restart began, report warning summaries, and fail the pass on any error/fatal entry. Never accept supervisor or RPC health without this restart-window log audit.
3536

3637
Treat supervisor state alone as insufficient. If build or proof fails, leave the new mirror head intact and retry the stale/missing build on the next heartbeat; never run the old `dist` against new source.
3738

.agents/skills/openclaw-live-updater/scripts/update-main.mjs

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,9 @@ function assertExactBuild(checkout, expectedSha) {
524524

525525
function restartGateway(runCommand, checkout, expectedSha) {
526526
assertExactBuild(checkout, expectedSha);
527+
const startedAtMs = Date.now();
527528
runCommand("pnpm", ["openclaw", "gateway", "restart"], checkout);
529+
return startedAtMs;
528530
}
529531

530532
function verifyGateway(runCommand, checkout, expectedSha) {
@@ -537,6 +539,69 @@ function verifyGateway(runCommand, checkout, expectedSha) {
537539
runCommand("pnpm", ["openclaw", "health", "--verbose", "--json"], checkout);
538540
}
539541

542+
function summarizeGatewayLogEntry(entry) {
543+
return {
544+
time: entry.time,
545+
level: entry.level,
546+
subsystem: entry.subsystem ?? null,
547+
message: String(entry.message ?? "").slice(0, 500),
548+
};
549+
}
550+
551+
export function parseGatewayLogAudit(output, sinceMs) {
552+
const entries = output
553+
.split("\n")
554+
.filter(Boolean)
555+
.flatMap((line) => {
556+
try {
557+
const entry = JSON.parse(line);
558+
const timestamp = Date.parse(entry.time ?? "");
559+
return entry.type === "log" && Number.isFinite(timestamp) && timestamp >= sinceMs
560+
? [entry]
561+
: [];
562+
} catch {
563+
return [];
564+
}
565+
});
566+
const errors = entries
567+
.filter((entry) => entry.level === "error" || entry.level === "fatal")
568+
.map(summarizeGatewayLogEntry);
569+
const warnings = entries.filter((entry) => entry.level === "warn").map(summarizeGatewayLogEntry);
570+
return {
571+
entries: entries.length,
572+
errorCount: errors.length,
573+
warningCount: warnings.length,
574+
errors: errors.slice(0, 20),
575+
warnings: warnings.slice(0, 20),
576+
};
577+
}
578+
579+
function defaultAuditGatewayLogs(checkout, sinceMs) {
580+
const output = execFileSync(
581+
process.execPath,
582+
[
583+
"openclaw.mjs",
584+
"logs",
585+
"--json",
586+
"--limit",
587+
"1000",
588+
"--max-bytes",
589+
"1000000",
590+
"--timeout",
591+
"10000",
592+
],
593+
{ cwd: checkout, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 },
594+
);
595+
const audit = parseGatewayLogAudit(output, sinceMs);
596+
if (audit.errorCount > 0) {
597+
throw new UpdateInvariantError(
598+
"gateway_restart_log_errors",
599+
`Gateway emitted ${audit.errorCount} error/fatal log entries after restart: ${JSON.stringify(audit.errors.slice(0, 5))}`,
600+
);
601+
}
602+
return audit;
603+
}
604+
540605
export function findExactMacTarget(processes, executable) {
541606
const target = processes
542607
.split("\n")
@@ -601,6 +666,8 @@ export function maintainMain(options, dependencies = {}) {
601666
}
602667
const runCommand = dependencies.runCommand ?? defaultRunCommand;
603668
const verifyMacTarget = dependencies.verifyMacTarget ?? defaultVerifyMacTarget;
669+
const auditGatewayLogs = dependencies.auditGatewayLogs ?? defaultAuditGatewayLogs;
670+
let gatewayLogAudit = null;
604671
let queuedMacState = null;
605672
if (actions.macAppRebuild) {
606673
queuedMacState = {
@@ -619,16 +686,18 @@ export function maintainMain(options, dependencies = {}) {
619686
if (actions.gatewayBuild) {
620687
runCommand("pnpm", ["build"], update.checkout);
621688
assertExactBuild(update.checkout, update.afterSha);
622-
restartGateway(runCommand, update.checkout, update.afterSha);
689+
const restartStartedAt = restartGateway(runCommand, update.checkout, update.afterSha);
623690
verifyGateway(runCommand, update.checkout, update.afterSha);
691+
gatewayLogAudit = auditGatewayLogs(update.checkout, restartStartedAt);
624692
} else {
625693
try {
626694
verifyGateway(runCommand, update.checkout, update.afterSha);
627695
} catch {
628696
actions.gatewayRestart = true;
629697
actions.gatewaySelfHeal = true;
630-
restartGateway(runCommand, update.checkout, update.afterSha);
698+
const restartStartedAt = restartGateway(runCommand, update.checkout, update.afterSha);
631699
verifyGateway(runCommand, update.checkout, update.afterSha);
700+
gatewayLogAudit = auditGatewayLogs(update.checkout, restartStartedAt);
632701
}
633702
}
634703
if (actions.macAppRebuild) {
@@ -664,6 +733,7 @@ export function maintainMain(options, dependencies = {}) {
664733
buildBefore,
665734
buildChangedPaths,
666735
actions,
736+
...(gatewayLogAudit ? { gatewayLogAudit } : {}),
667737
...(maintenanceState.macTarget ? { macTarget: maintenanceState.macTarget } : {}),
668738
};
669739
} finally {

scripts/restart-mac.sh

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -269,15 +269,13 @@ stop_launch_agent() {
269269
launchctl bootout gui/"$UID"/ai.openclaw.mac 2>/dev/null || true
270270
}
271271

272-
# 1) Stop only the process set selected by the requested mode.
272+
# 1) Validate the process set selected by the requested mode. Target-only keeps
273+
# the current managed app alive while the replacement builds and signs.
273274
if [[ "$TARGET_ONLY" -eq 1 ]]; then
274275
if [[ -n "$(foreign_openclaw_process_pids)" ]]; then
275276
fail "Another OpenClaw app or test process is active; target-only restart deferred"
276277
fi
277-
log "==> Killing managed installed and exact target OpenClaw instances"
278-
if ! kill_managed_openclaw; then
279-
fail "Managed OpenClaw instances did not exit after cleanup attempts"
280-
fi
278+
log "==> Keeping managed OpenClaw running while the replacement builds"
281279
else
282280
stop_launch_agent
283281
log "==> Killing existing OpenClaw instances"
@@ -378,6 +376,16 @@ if [[ "$ATTACH_ONLY" -eq 1 ]]; then
378376
ATTACH_ONLY_ARGS+=(--args --attach-only)
379377
fi
380378

379+
if [[ "$TARGET_ONLY" -eq 1 ]]; then
380+
if [[ -n "$(foreign_openclaw_process_pids)" ]]; then
381+
fail "Another OpenClaw app or test process appeared during build; target-only restart deferred"
382+
fi
383+
log "==> Switching managed installed and exact target OpenClaw instances"
384+
if ! kill_managed_openclaw; then
385+
fail "Managed OpenClaw instances did not exit after cleanup attempts"
386+
fi
387+
fi
388+
381389
# 4) Launch the installed app in the foreground so the menu bar extra appears.
382390
# LaunchServices can inherit a huge environment from this shell (secrets, prompt vars, etc.).
383391
# That can cause launchd spawn failures and is undesirable for a GUI app anyway.

test/scripts/openclaw-live-updater.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
mkdirSync,
66
mkdtempSync,
77
readFileSync,
8+
realpathSync,
89
renameSync,
910
rmSync,
1011
symlinkSync,
@@ -21,6 +22,7 @@ import {
2122
inspectBuildState,
2223
maintainMain,
2324
originMatches,
25+
parseGatewayLogAudit,
2426
} from "../../.agents/skills/openclaw-live-updater/scripts/update-main.mjs";
2527
import {
2628
BUILD_STAMP_FILE,
@@ -48,7 +50,17 @@ function maintainFixture(
4850
options: Record<string, unknown>,
4951
dependencies: Record<string, unknown> = {},
5052
) {
51-
return maintainMain(options, { fetchMain: fetchFixtureMain, ...dependencies });
53+
return maintainMain(options, {
54+
fetchMain: fetchFixtureMain,
55+
auditGatewayLogs: () => ({
56+
entries: 0,
57+
errorCount: 0,
58+
warningCount: 0,
59+
errors: [],
60+
warnings: [],
61+
}),
62+
...dependencies,
63+
});
5264
}
5365

5466
function makeFixture() {
@@ -72,6 +84,7 @@ function makeFixture() {
7284
const canonicalOrigin = "https://github.com/openclaw/openclaw.git";
7385
git(mirror, "remote", "set-url", "origin", canonicalOrigin);
7486
fixtureOrigins.set(mirror, origin);
87+
fixtureOrigins.set(realpathSync(mirror), origin);
7588
return { root, mirror, origin, seed };
7689
}
7790

@@ -116,6 +129,53 @@ function fakeCommands(mirror: string) {
116129
}
117130

118131
describe("openclaw live updater", () => {
132+
test("audits only error and warning logs emitted after Gateway restart", () => {
133+
const output = [
134+
{ type: "meta", file: "/tmp/openclaw.log" },
135+
{ type: "log", time: "2026-07-11T08:00:00.000Z", level: "error", message: "old" },
136+
{ type: "log", time: "2026-07-11T08:00:02.000Z", level: "info", message: "ready" },
137+
{
138+
type: "log",
139+
time: "2026-07-11T08:00:03.000Z",
140+
level: "warn",
141+
subsystem: "gateway",
142+
message: "degraded",
143+
},
144+
{
145+
type: "log",
146+
time: "2026-07-11T08:00:04.000Z",
147+
level: "fatal",
148+
subsystem: "gateway",
149+
message: "failed",
150+
},
151+
{ type: "notice", message: "done" },
152+
]
153+
.map((entry) => JSON.stringify(entry))
154+
.join("\n");
155+
156+
expect(parseGatewayLogAudit(output, Date.parse("2026-07-11T08:00:02.000Z"))).toEqual({
157+
entries: 3,
158+
errorCount: 1,
159+
warningCount: 1,
160+
errors: [
161+
{
162+
time: "2026-07-11T08:00:04.000Z",
163+
level: "fatal",
164+
subsystem: "gateway",
165+
message: "failed",
166+
},
167+
],
168+
warnings: [
169+
{
170+
time: "2026-07-11T08:00:03.000Z",
171+
level: "warn",
172+
subsystem: "gateway",
173+
message: "degraded",
174+
},
175+
],
176+
});
177+
});
178+
119179
test("accepts supported OpenClaw GitHub origins", () => {
120180
expect(originMatches("https://github.com/openclaw/openclaw.git")).toBe(true);
121181
expect(originMatches("[email protected]:openclaw/openclaw.git")).toBe(true);
@@ -275,6 +335,13 @@ describe("openclaw live updater", () => {
275335
macAppRebuild: true,
276336
macUiVerification: true,
277337
});
338+
expect(output.gatewayLogAudit).toEqual({
339+
entries: 0,
340+
errorCount: 0,
341+
warningCount: 0,
342+
errors: [],
343+
warnings: [],
344+
});
278345
expect(commands.calls).toEqual([
279346
"pnpm install --frozen-lockfile",
280347
"pnpm build",

test/scripts/restart-mac.test.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -372,20 +372,37 @@ describe("scripts/restart-mac.sh", () => {
372372

373373
it("target-only mode refuses foreign app processes without broad cleanup", () => {
374374
const script = readFileSync(restartScriptPath, "utf8");
375-
const targetBlock = script.slice(
375+
const initialTargetBlock = script.slice(
376376
script.indexOf('if [[ "$TARGET_ONLY" -eq 1 ]]; then', script.indexOf("# 1)")),
377377
script.indexOf("else", script.indexOf("# 1)")),
378378
);
379+
const switchTargetBlock = script.slice(
380+
script.indexOf('if [[ "$TARGET_ONLY" -eq 1 ]]; then', script.indexOf("ATTACH_ONLY_ARGS")),
381+
script.indexOf("# 4) Launch"),
382+
);
379383

380-
expect(targetBlock).toContain("foreign_openclaw_process_pids");
381-
expect(targetBlock).toContain("kill_managed_openclaw");
382-
expect(targetBlock).not.toContain("stop_launch_agent");
383-
expect(targetBlock).not.toContain("kill_all_openclaw");
384+
expect(initialTargetBlock).toContain("foreign_openclaw_process_pids");
385+
expect(initialTargetBlock).not.toContain("kill_managed_openclaw");
386+
expect(initialTargetBlock).not.toContain("stop_launch_agent");
387+
expect(initialTargetBlock).not.toContain("kill_all_openclaw");
388+
expect(switchTargetBlock).toContain("foreign_openclaw_process_pids");
389+
expect(switchTargetBlock).toContain("kill_managed_openclaw");
384390
expect(script).toContain('[[ "${executable}" == "${TARGET_EXECUTABLE}" ]] && continue');
385391
expect(script).toContain('process_pids_for_executable "${TARGET_EXECUTABLE}"');
386392
expect(script).toContain("target-only restart deferred");
387393
});
388394

395+
it("keeps the managed app alive until the signed replacement is ready", () => {
396+
const script = readFileSync(restartScriptPath, "utf8");
397+
const packageIndex = script.indexOf('run_step "package app"');
398+
const switchIndex = script.indexOf('log "==> Switching managed installed');
399+
const launchIndex = script.indexOf('run_step "launch app"');
400+
401+
expect(packageIndex).toBeGreaterThan(-1);
402+
expect(switchIndex).toBeGreaterThan(packageIndex);
403+
expect(launchIndex).toBeGreaterThan(switchIndex);
404+
});
405+
389406
it("escalates only exact managed app processes when graceful shutdown stalls", () => {
390407
const script = readFileSync(restartScriptPath, "utf8");
391408
const managedKillBlock = script.slice(

0 commit comments

Comments
 (0)