File size: 6,192 Bytes
eb3f11e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
import fs from "node:fs";
import path from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { createDeferred } from "../../../test/helpers/promise.js";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import * as temporaryRoot from "../../infra/tmp-openclaw-dir.js";
import { createManagedHandoffLeaseStore } from "../../infra/update-managed-service-handoff-lease.js";
import {
  createUpdateRun,
  getUpdateRun,
  recordUpdateRunPhase,
} from "../../infra/update-run-ledger.js";
import { defaultRuntime } from "../../runtime.js";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import {
  withUpdateCommandExecutor,
  withUpdateCommandExecutorChild,
} from "./update-command-executor.js";
import { withUpdateCommandTerminalResult } from "./update-command-terminal.js";

const dirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(() => {
  vi.useRealTimers();
  closeOpenClawStateDatabaseForTest();
  vi.restoreAllMocks();
});

it("records an activation timeout when executor settlement remains pending", async () => {
  const root = fs.realpathSync(dirs.make("update-activation-"));
  const temporary = path.join(root, "private-tmp");
  fs.mkdirSync(temporary, { mode: 0o700 });
  vi.spyOn(temporaryRoot, "resolvePreferredOpenClawTmpDir").mockReturnValue(temporary);
  const env = { ...process.env, OPENCLAW_STATE_DIR: path.join(root, "state") };
  const run = { runId: createUpdateRun({ trigger: "cli" }, { env }).runId, env };
  const output = vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => {});
  vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
  const entered = createDeferred();
  const release = createDeferred();
  let childWork: Promise<unknown> | undefined;
  let outcome: unknown;
  let assertCurrent: (() => void) | undefined;
  const budget = 10 * 60_000;
  vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
  const running = withUpdateCommandTerminalResult(
    async (registerRun) => {
      registerRun(run);
      await withUpdateCommandExecutor(run.runId, async (executor) => {
        const fence = await executor.enter(root, { activationTimeoutMs: budget });
        assertCurrent = fence.assertCurrent;
        recordUpdateRunPhase(run.runId, "activating", undefined, { env });
        childWork = withUpdateCommandExecutorChild(fence, root, async () => {
          entered.resolve();
          await release.promise;
        });
        void childWork.catch(() => {});
        await entered.promise;
        // Returning does not settle the admitted child's outstanding work.
      });
    },
    { json: true },
  ).catch((error: unknown) => {
    outcome = error;
  });
  try {
    await entered.promise;
    await vi.advanceTimersByTimeAsync(budget - 1);
    expect(getUpdateRun(run.runId, { env })?.status).toBe("running");
    expect(assertCurrent).toBeDefined();
    await vi.advanceTimersByTimeAsync(budget + 1);
    await running;
    expect(outcome).toMatchObject({ result: { reason: "update-activation-timeout" } });
    expect(assertCurrent).toThrow("activation");
    expect(getUpdateRun(run.runId, { env })).toMatchObject({
      phase: "finished",
      status: "failed",
      reason: "update-activation-timeout",
    });
    expect(output).toHaveBeenCalledWith(
      expect.objectContaining({
        reason: "update-activation-timeout",
        status: "error",
      }),
    );
    expect(createManagedHandoffLeaseStore().read(root).kind).toBe("current");
    await expect(
      withUpdateCommandExecutor(run.runId, (other) => other.enter(root)),
    ).rejects.toThrow("Another update executor");
  } finally {
    release.resolve();
    await childWork?.catch(() => {});
    await running;
  }
});

it.each([false, true])(
  "checks the activation deadline after synchronous work (expired: %s)",
  async (expired) => {
    const root = fs.realpathSync(dirs.make("update-activation-clock-"));
    const temporary = path.join(root, "private-tmp");
    fs.mkdirSync(temporary, { mode: 0o700 });
    vi.spyOn(temporaryRoot, "resolvePreferredOpenClawTmpDir").mockReturnValue(temporary);
    vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
    const work = withUpdateCommandExecutor("clock-probe", async (executor) => {
      await executor.enter(root, { activationTimeoutMs: 60_000 });
      // Move the clock without dispatching timers, as a blocking native probe can.
      vi.setSystemTime(Date.now() + (expired ? 60_001 : 59_999));
      return "completed";
    });
    if (expired) {
      await expect(work).rejects.toMatchObject({ reason: "update-activation-timeout" });
    } else {
      await expect(work).resolves.toBe("completed");
    }
    expect(createManagedHandoffLeaseStore().read(root)).toEqual({ kind: "absent" });
    expect(vi.getTimerCount()).toBe(0);
  },
);

it.each([undefined, 48 * 60 * 60_000])(
  "keeps activation alive for measured state and caller allowance %s",
  async (callerTimeoutMs) => {
    const { resolveUpdateFinalizationTimeoutMs } =
      await import("../../infra/update-finalization-budget.js");
    const root = fs.realpathSync(dirs.make("update-activation-size-"));
    const database = path.join(root, "agent.sqlite");
    const descriptor = fs.openSync(database, "w");
    fs.ftruncateSync(descriptor, 2 * 1024 ** 3);
    fs.closeSync(descriptor);
    const budget = await resolveUpdateFinalizationTimeoutMs(callerTimeoutMs, {
      databases: [{ path: database }],
      env: { ...process.env, OPENCLAW_STATE_DIR: root },
    });
    const legacyBudget = Math.max(30 * 60_000, (callerTimeoutMs ?? 0) * 6);
    vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
    await expect(
      withUpdateCommandExecutor("measured-activation", async (executor) => {
        const fence = await executor.enter(root, { activationTimeoutMs: budget });
        vi.setSystemTime(Date.now() + (callerTimeoutMs ?? legacyBudget + 1));
        fence.assertCurrent();
        return "completed";
      }),
    ).resolves.toBe("completed");
    expect(createManagedHandoffLeaseStore().read(root)).toEqual({ kind: "absent" });
  },
);