From abe7381c91abe9533da36cfc097b0a01557c3400 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 28 May 2026 18:14:44 +0900 Subject: [PATCH] fix(background-agent): defer unsafe parent wakes --- .../parent-wake-assistant-blocking.test.ts | 214 ++++++-------- .../parent-wake-history-deferral.test.ts | 268 ++++++++++++++++++ .../parent-wake-message-activity.ts | 78 +++++ .../background-agent/parent-wake-notifier.ts | 63 ++-- .../parent-wake-user-message-race.test.ts | 6 +- 5 files changed, 468 insertions(+), 161 deletions(-) create mode 100644 src/features/background-agent/parent-wake-history-deferral.test.ts create mode 100644 src/features/background-agent/parent-wake-message-activity.ts diff --git a/src/features/background-agent/parent-wake-assistant-blocking.test.ts b/src/features/background-agent/parent-wake-assistant-blocking.test.ts index fa855e635..e70b0ed7b 100644 --- a/src/features/background-agent/parent-wake-assistant-blocking.test.ts +++ b/src/features/background-agent/parent-wake-assistant-blocking.test.ts @@ -19,138 +19,6 @@ type PromptAsyncCall = { type ParentWakeClient = ConstructorParameters[0]["client"] describe("ParentWakeNotifier — assistant turn blocking", () => { - test("#given stale unfinished assistant text has no pending tool call #when checking parent wake history #then parent wake dispatches after defer max", async () => { - // given - const originalDateNow = Date.now - Date.now = () => 100_000 - const client = unsafeTestValue({ - session: { - messages: async () => ({ - data: [ - { - info: { - role: "assistant", - finish: "unknown", - time: { created: 90_000 }, - }, - parts: [{ type: "text", text: "still streaming" }], - }, - ], - }), - status: async () => ({ data: { "parent-stale-text": { type: "idle" } } }), - promptAsync: async () => { - return { data: {} } - }, - }, - }) - const notifier = new ParentWakeNotifier( - { - client, - directory: "/tmp/test-omo", - enqueueNotificationForParent: async (_sessionID, operation) => { - await operation() - }, - }, - { - pendingRetryMs: 1_000, - acceptedMessageSkewMs: 5_000, - toolCallDeferMaxMs: 5_000, - failureRequeueWindowMs: 5_000, - userMessageInProgressWindowMs: 2_000, - }, - ) - notifier.queuePendingParentWake( - "parent-stale-text", - "task complete", - { agent: "sisyphus" }, - true, - ) - const pendingWake = notifier.getPendingParentWakes().get("parent-stale-text") - expect(pendingWake).toBeDefined() - if (!pendingWake) { - throw new Error("Missing pending parent wake") - } - pendingWake.toolCallDeferralStartedAt = 90_000 - - try { - // when - const decision = await notifier["shouldDeferParentWakeForSessionHistory"]("parent-stale-text", pendingWake) - - // then - expect(decision).toEqual({ defer: false, skipPromptGateToolStateCheck: false }) - } finally { - Date.now = originalDateNow - notifier.shutdown() - releaseAllPromptAsyncReservationsForTesting() - } - }) - - test("#given fresh unfinished assistant text has no pending tool call #when checking parent wake history #then parent wake continues deferring", async () => { - // given - const originalDateNow = Date.now - Date.now = () => 100_000 - const client = unsafeTestValue({ - session: { - messages: async () => ({ - data: [ - { - info: { - role: "assistant", - finish: "unknown", - time: { created: 99_000 }, - }, - parts: [{ type: "text", text: "still streaming" }], - }, - ], - }), - status: async () => ({ data: { "parent-fresh-text": { type: "idle" } } }), - promptAsync: async () => { - return { data: {} } - }, - }, - }) - const notifier = new ParentWakeNotifier( - { - client, - directory: "/tmp/test-omo", - enqueueNotificationForParent: async (_sessionID, operation) => { - await operation() - }, - }, - { - pendingRetryMs: 1_000, - acceptedMessageSkewMs: 5_000, - toolCallDeferMaxMs: 5_000, - failureRequeueWindowMs: 5_000, - userMessageInProgressWindowMs: 2_000, - }, - ) - notifier.queuePendingParentWake( - "parent-fresh-text", - "task complete", - { agent: "sisyphus" }, - true, - ) - const pendingWake = notifier.getPendingParentWakes().get("parent-fresh-text") - expect(pendingWake).toBeDefined() - if (!pendingWake) { - throw new Error("Missing pending parent wake") - } - pendingWake.toolCallDeferralStartedAt = 98_000 - - try { - // when - const decision = await notifier["shouldDeferParentWakeForSessionHistory"]("parent-fresh-text", pendingWake) - - // then - expect(decision).toEqual({ defer: true, skipPromptGateToolStateCheck: false }) - } finally { - Date.now = originalDateNow - notifier.shutdown() - releaseAllPromptAsyncReservationsForTesting() - } - }) - test("#given notifier sees an unfinished assistant but prompt gate message fetch fails #when flushing pending wake #then the wake stays pending", async () => { // given const promptAsyncCalls: PromptAsyncCall[] = [] @@ -216,4 +84,86 @@ describe("ParentWakeNotifier — assistant turn blocking", () => { notifier.shutdown() releaseAllPromptAsyncReservationsForTesting() }) + + test("#given stale completed assistant question tool has no real user answer #when flushing pending wake #then wake stays pending", async () => { + // given + const originalDateNow = Date.now + Date.now = () => 100_000 + const promptAsyncCalls: PromptAsyncCall[] = [] + const client = unsafeTestValue({ + session: { + messages: async () => ({ + data: [ + { + info: { + role: "user", + time: { created: 10_000 }, + }, + parts: [{ type: "text", text: "start work" }], + }, + { + info: { + role: "assistant", + finish: "tool-calls", + time: { created: 20_000, completed: 99_000 }, + }, + parts: [ + { + type: "tool", + tool: "question", + state: { status: "error" }, + }, + ], + }, + ], + }), + status: async () => ({ data: { "parent-question-unanswered": { type: "idle" } } }), + promptAsync: async (call: PromptAsyncCall) => { + promptAsyncCalls.push(call) + return { data: {} } + }, + }, + }) + const notifier = new ParentWakeNotifier( + { + client, + directory: "/tmp/test-omo", + enqueueNotificationForParent: async (_sessionID, operation) => { + await operation() + }, + }, + { + pendingRetryMs: 1_000, + acceptedMessageSkewMs: 5_000, + toolCallDeferMaxMs: 5_000, + failureRequeueWindowMs: 5_000, + userMessageInProgressWindowMs: 2_000, + }, + ) + notifier.queuePendingParentWake( + "parent-question-unanswered", + "task complete", + { agent: "sisyphus" }, + true, + ) + const pendingWake = notifier.getPendingParentWakes().get("parent-question-unanswered") + expect(pendingWake).toBeDefined() + if (!pendingWake) { + throw new Error("Missing pending parent wake") + } + pendingWake.toolCallDeferralStartedAt = 1_000 + + try { + // when + await notifier.flushPendingParentWake("parent-question-unanswered") + + // then + expect(promptAsyncCalls).toHaveLength(0) + expect(notifier.getPendingParentWakes().has("parent-question-unanswered")).toBe(true) + } finally { + Date.now = originalDateNow + notifier.shutdown() + releaseAllPromptAsyncReservationsForTesting() + } + }) }) diff --git a/src/features/background-agent/parent-wake-history-deferral.test.ts b/src/features/background-agent/parent-wake-history-deferral.test.ts new file mode 100644 index 000000000..c73450aa3 --- /dev/null +++ b/src/features/background-agent/parent-wake-history-deferral.test.ts @@ -0,0 +1,268 @@ +/// + +import { describe, expect, test } from "bun:test" +import { releaseAllPromptAsyncReservationsForTesting } from "../../hooks/shared/prompt-async-gate" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" +import { ParentWakeNotifier } from "./parent-wake-notifier" + +type ParentWakeClient = ConstructorParameters[0]["client"] + +describe("ParentWakeNotifier — assistant history deferral", () => { + test("#given stale unfinished assistant text has no pending tool call #when checking parent wake history #then parent wake dispatches after defer max", async () => { + // given + const originalDateNow = Date.now + Date.now = () => 100_000 + const client = unsafeTestValue({ + session: { + messages: async () => ({ + data: [ + { + info: { + role: "assistant", + finish: "unknown", + time: { created: 90_000 }, + }, + parts: [{ type: "text", text: "still streaming" }], + }, + ], + }), + status: async () => ({ data: { "parent-stale-text": { type: "idle" } } }), + promptAsync: async () => { + return { data: {} } + }, + }, + }) + const notifier = new ParentWakeNotifier( + { + client, + directory: "/tmp/test-omo", + enqueueNotificationForParent: async (_sessionID, operation) => { + await operation() + }, + }, + { + pendingRetryMs: 1_000, + acceptedMessageSkewMs: 5_000, + toolCallDeferMaxMs: 5_000, + failureRequeueWindowMs: 5_000, + userMessageInProgressWindowMs: 2_000, + }, + ) + notifier.queuePendingParentWake( + "parent-stale-text", + "task complete", + { agent: "sisyphus" }, + true, + ) + const pendingWake = notifier.getPendingParentWakes().get("parent-stale-text") + expect(pendingWake).toBeDefined() + if (!pendingWake) { + throw new Error("Missing pending parent wake") + } + pendingWake.toolCallDeferralStartedAt = 90_000 + + try { + // when + const decision = await notifier["shouldDeferParentWakeForSessionHistory"]("parent-stale-text", pendingWake) + + // then + expect(decision).toEqual({ defer: false, skipPromptGateToolStateCheck: false }) + } finally { + Date.now = originalDateNow + notifier.shutdown() + releaseAllPromptAsyncReservationsForTesting() + } + }) + + test("#given fresh unfinished assistant text has no pending tool call #when checking parent wake history #then parent wake continues deferring", async () => { + // given + const originalDateNow = Date.now + Date.now = () => 100_000 + const client = unsafeTestValue({ + session: { + messages: async () => ({ + data: [ + { + info: { + role: "assistant", + finish: "unknown", + time: { created: 99_000 }, + }, + parts: [{ type: "text", text: "still streaming" }], + }, + ], + }), + status: async () => ({ data: { "parent-fresh-text": { type: "idle" } } }), + promptAsync: async () => { + return { data: {} } + }, + }, + }) + const notifier = new ParentWakeNotifier( + { + client, + directory: "/tmp/test-omo", + enqueueNotificationForParent: async (_sessionID, operation) => { + await operation() + }, + }, + { + pendingRetryMs: 1_000, + acceptedMessageSkewMs: 5_000, + toolCallDeferMaxMs: 5_000, + failureRequeueWindowMs: 5_000, + userMessageInProgressWindowMs: 2_000, + }, + ) + notifier.queuePendingParentWake( + "parent-fresh-text", + "task complete", + { agent: "sisyphus" }, + true, + ) + const pendingWake = notifier.getPendingParentWakes().get("parent-fresh-text") + expect(pendingWake).toBeDefined() + if (!pendingWake) { + throw new Error("Missing pending parent wake") + } + pendingWake.toolCallDeferralStartedAt = 98_000 + + try { + // when + const decision = await notifier["shouldDeferParentWakeForSessionHistory"]("parent-fresh-text", pendingWake) + + // then + expect(decision).toEqual({ defer: true, skipPromptGateToolStateCheck: false }) + } finally { + Date.now = originalDateNow + notifier.shutdown() + releaseAllPromptAsyncReservationsForTesting() + } + }) + + test("#given parent session messages cannot be inspected #when checking parent wake history #then parent wake stays deferred", async () => { + // given + const client = unsafeTestValue({ + session: { + messages: async () => { + throw new Error("message endpoint failed") + }, + status: async () => ({ data: { "parent-message-error": { type: "idle" } } }), + promptAsync: async () => { + return { data: {} } + }, + }, + }) + const notifier = new ParentWakeNotifier( + { + client, + directory: "/tmp/test-omo", + enqueueNotificationForParent: async (_sessionID, operation) => { + await operation() + }, + }, + { + pendingRetryMs: 1_000, + acceptedMessageSkewMs: 5_000, + toolCallDeferMaxMs: 5_000, + failureRequeueWindowMs: 5_000, + userMessageInProgressWindowMs: 2_000, + }, + ) + notifier.queuePendingParentWake( + "parent-message-error", + "task complete", + { agent: "sisyphus" }, + true, + ) + const pendingWake = notifier.getPendingParentWakes().get("parent-message-error") + expect(pendingWake).toBeDefined() + if (!pendingWake) { + throw new Error("Missing pending parent wake") + } + + try { + // when + const decision = await notifier["shouldDeferParentWakeForSessionHistory"]("parent-message-error", pendingWake) + + // then + expect(decision).toEqual({ defer: true, skipPromptGateToolStateCheck: false }) + } finally { + notifier.shutdown() + releaseAllPromptAsyncReservationsForTesting() + } + }) + + test("#given old assistant turn has recent running tool activity #when checking parent wake history #then stale tool escape stays deferred", async () => { + // given + const originalDateNow = Date.now + Date.now = () => 100_000 + const client = unsafeTestValue({ + session: { + messages: async () => ({ + data: [ + { + info: { + role: "assistant", + finish: "tool-calls", + time: { created: 80_000 }, + }, + parts: [ + { + type: "tool", + tool: "bash", + time: { start: 99_000, end: 99_500 }, + state: { status: "running" }, + }, + ], + }, + ], + }), + status: async () => ({ data: { "parent-fresh-tool-activity": { type: "idle" } } }), + promptAsync: async () => { + return { data: {} } + }, + }, + }) + const notifier = new ParentWakeNotifier( + { + client, + directory: "/tmp/test-omo", + enqueueNotificationForParent: async (_sessionID, operation) => { + await operation() + }, + }, + { + pendingRetryMs: 1_000, + acceptedMessageSkewMs: 5_000, + toolCallDeferMaxMs: 5_000, + failureRequeueWindowMs: 5_000, + userMessageInProgressWindowMs: 2_000, + }, + ) + notifier.queuePendingParentWake( + "parent-fresh-tool-activity", + "task complete", + { agent: "sisyphus" }, + true, + ) + const pendingWake = notifier.getPendingParentWakes().get("parent-fresh-tool-activity") + expect(pendingWake).toBeDefined() + if (!pendingWake) { + throw new Error("Missing pending parent wake") + } + pendingWake.toolCallDeferralStartedAt = 90_000 + + try { + // when + const decision = await notifier["shouldDeferParentWakeForSessionHistory"]("parent-fresh-tool-activity", pendingWake) + + // then + expect(decision).toEqual({ defer: true, skipPromptGateToolStateCheck: false }) + } finally { + Date.now = originalDateNow + notifier.shutdown() + releaseAllPromptAsyncReservationsForTesting() + } + }) +}) diff --git a/src/features/background-agent/parent-wake-message-activity.ts b/src/features/background-agent/parent-wake-message-activity.ts new file mode 100644 index 000000000..9de5bd63a --- /dev/null +++ b/src/features/background-agent/parent-wake-message-activity.ts @@ -0,0 +1,78 @@ +type ParentWakeMessageTime = { + readonly created?: unknown + readonly updated?: unknown + readonly completed?: unknown + readonly start?: unknown + readonly end?: unknown +} + +type ParentWakeMessageActivityPart = { + readonly time?: ParentWakeMessageTime + readonly state?: { + readonly time?: ParentWakeMessageTime + } +} + +type ParentWakeMessageActivity = { + readonly info?: { + readonly time?: ParentWakeMessageTime + } + readonly time?: ParentWakeMessageTime + readonly parts?: readonly ParentWakeMessageActivityPart[] +} + +function timestampFromUnknown(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value + } + if (typeof value === "string") { + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : undefined + } + if (value instanceof Date) { + return value.getTime() + } + return undefined +} + +function latestTimestamp(...values: readonly unknown[]): number | undefined { + let latest: number | undefined + for (const value of values) { + const timestamp = timestampFromUnknown(value) + if (timestamp === undefined) { + continue + } + if (latest === undefined || timestamp > latest) { + latest = timestamp + } + } + return latest +} + +function latestTimeActivity(time: ParentWakeMessageTime | undefined): number | undefined { + if (!time) { + return undefined + } + return latestTimestamp(time.created, time.updated, time.completed, time.start, time.end) +} + +export function getParentWakeMessageCreatedAt(message: ParentWakeMessageActivity): number | undefined { + return timestampFromUnknown(message.info?.time?.created ?? message.time?.created) +} + +export function getParentWakeMessageActivityAt(message: ParentWakeMessageActivity): number | undefined { + let latest = latestTimestamp( + latestTimeActivity(message.info?.time), + latestTimeActivity(message.time), + ) + for (const part of message.parts ?? []) { + const partActivity = latestTimestamp( + latestTimeActivity(part.time), + latestTimeActivity(part.state?.time), + ) + if (partActivity !== undefined && (latest === undefined || partActivity > latest)) { + latest = partActivity + } + } + return latest +} diff --git a/src/features/background-agent/parent-wake-notifier.ts b/src/features/background-agent/parent-wake-notifier.ts index c3800ac8d..fa6c80ce1 100644 --- a/src/features/background-agent/parent-wake-notifier.ts +++ b/src/features/background-agent/parent-wake-notifier.ts @@ -7,8 +7,12 @@ import { } from "../../shared" import { isSessionActive as isOpenCodeSessionActive, settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle" import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../../hooks/shared/prompt-async-gate" +import { isPromptMessageInspectionAborted } from "../../shared/prompt-async-gate/message-inspection-error" import type { PromptDispatchClient } from "../../shared/prompt-async-gate/types" -import { latestAssistantTurnBlocksInternalPrompt } from "../../shared/prompt-async-gate/pending-tool-turn" +import { + latestAssistantTurnBlocksInternalPrompt, + latestAssistantTurnHasUnansweredQuestion, +} from "../../shared/prompt-async-gate/pending-tool-turn" import type { PluginInput } from "@opencode-ai/plugin" import { cloneParentWake, @@ -17,6 +21,7 @@ import { type ParentWakePromptContext, type PendingParentWake, } from "./parent-wake-dedupe" +import { getParentWakeMessageActivityAt, getParentWakeMessageCreatedAt } from "./parent-wake-message-activity" type OpencodeClient = PluginInput["client"] type ParentWakeNotifierClient = PromptDispatchClient & { @@ -32,18 +37,20 @@ type ParentWakeSessionMessage = { info?: { role?: string finish?: string - time?: { created?: unknown } + time?: { created?: unknown; updated?: unknown; completed?: unknown; start?: unknown; end?: unknown } } role?: string finish?: string - time?: { created?: unknown } + time?: { created?: unknown; updated?: unknown; completed?: unknown; start?: unknown; end?: unknown } parts?: Array<{ type?: string text?: string synthetic?: boolean content?: unknown + time?: { created?: unknown; updated?: unknown; completed?: unknown; start?: unknown; end?: unknown } state?: { status?: unknown + time?: { created?: unknown; updated?: unknown; completed?: unknown; start?: unknown; end?: unknown } } }> } @@ -393,7 +400,7 @@ export class ParentWakeNotifier { this.dispatchedParentWakeTimers.set(sessionID, timer) } - private async loadParentWakeSessionMessages(sessionID: string): Promise { + private async loadParentWakeSessionMessages(sessionID: string): Promise { try { const messagesResp = await this.deps.client.session.messages({ path: { id: sessionID }, @@ -405,7 +412,7 @@ export class ParentWakeNotifier { sessionID, error, }) - return [] + return isPromptMessageInspectionAborted(error) ? [] : undefined } } @@ -417,21 +424,6 @@ export class ParentWakeNotifier { return message.info?.finish ?? message.finish } - private getParentWakeMessageCreatedAt(message: ParentWakeSessionMessage): number | undefined { - const value = message.info?.time?.created ?? message.time?.created - if (typeof value === "number" && Number.isFinite(value)) { - return value - } - if (typeof value === "string") { - const parsed = Date.parse(value) - return Number.isFinite(parsed) ? parsed : undefined - } - if (value instanceof Date) { - return value.getTime() - } - return undefined - } - private parentWakePartIsWaitingOnTool(part: NonNullable[number]): boolean { if ( part.type !== "tool" @@ -448,7 +440,7 @@ export class ParentWakeNotifier { private latestAssistantToolWaitState(messages: ParentWakeSessionMessage[]): { waiting: boolean - createdAt?: number + activityAt?: number } { for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index] @@ -460,7 +452,7 @@ export class ParentWakeNotifier { const waiting = this.getParentWakeMessageFinish(message) === "tool-calls" || message.parts?.some((part) => this.parentWakePartIsWaitingOnTool(part)) === true return waiting - ? { waiting: true, createdAt: this.getParentWakeMessageCreatedAt(message) } + ? { waiting: true, activityAt: getParentWakeMessageActivityAt(message) } : { waiting: false } } if (role === "user") { @@ -522,6 +514,9 @@ export class ParentWakeNotifier { return false } const messages = await this.loadParentWakeSessionMessages(sessionID) + if (!messages) { + return true + } for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index] if (!message) { @@ -532,7 +527,7 @@ export class ParentWakeNotifier { if (isSyntheticOrInternalUserMessage(message)) { continue } - const createdAt = this.getParentWakeMessageCreatedAt(message) + const createdAt = getParentWakeMessageCreatedAt(message) if (createdAt === undefined) { return false } @@ -552,7 +547,14 @@ export class ParentWakeNotifier { wake: PendingParentWake, ): Promise { const messages = await this.loadParentWakeSessionMessages(sessionID) + if (!messages) { + log("[background-agent] Deferred parent wake because parent messages could not be inspected:", { + sessionID, + }) + return { defer: true, skipPromptGateToolStateCheck: false } + } const latestAssistantBlocksPrompt = latestAssistantTurnBlocksInternalPrompt(messages) + const latestAssistantHasUnansweredQuestion = latestAssistantTurnHasUnansweredQuestion(messages) const toolWaitState = this.latestAssistantToolWaitState(messages) if (!latestAssistantBlocksPrompt) { delete wake.toolCallDeferralStartedAt @@ -560,9 +562,15 @@ export class ParentWakeNotifier { } const now = Date.now() wake.toolCallDeferralStartedAt ??= now - const latestToolWaitAgeMs = toolWaitState.createdAt === undefined + if (latestAssistantHasUnansweredQuestion) { + log("[background-agent] Deferred parent wake because latest assistant question awaits user response:", { + sessionID, + }) + return { defer: true, skipPromptGateToolStateCheck: false } + } + const latestToolWaitAgeMs = toolWaitState.activityAt === undefined ? 0 - : now - toolWaitState.createdAt + : now - toolWaitState.activityAt const deferAge = now - wake.toolCallDeferralStartedAt if ( wake.shouldReply @@ -594,8 +602,11 @@ export class ParentWakeNotifier { } const dispatchedAt = wake.dispatchedAt const messages = await this.loadParentWakeSessionMessages(sessionID) + if (!messages) { + return false + } return messages.some((message) => { - const createdAt = this.getParentWakeMessageCreatedAt(message) + const createdAt = getParentWakeMessageCreatedAt(message) if (createdAt === undefined) { return false } diff --git a/src/features/background-agent/parent-wake-user-message-race.test.ts b/src/features/background-agent/parent-wake-user-message-race.test.ts index ffcc72568..feb5ec35e 100644 --- a/src/features/background-agent/parent-wake-user-message-race.test.ts +++ b/src/features/background-agent/parent-wake-user-message-race.test.ts @@ -581,7 +581,7 @@ describe("ParentWakeNotifier — user message race guard (issue #4120)", () => { } }) - test("#given only an internal user tail is fresh #when flushing pending wake #then user race guard does not defer", async () => { + test("#given only an internal user tail is fresh #when flushing pending wake #then parent wake remains deferred", async () => { // given const originalDateNow = Date.now Date.now = () => 100_000 @@ -615,8 +615,8 @@ describe("ParentWakeNotifier — user message race guard (issue #4120)", () => { await notifier.flushPendingParentWake("parent-internal-tail-user-race") // then - expect(promptAsyncCalls).toHaveLength(1) - expect(notifier.getPendingParentWakes().has("parent-internal-tail-user-race")).toBe(false) + expect(promptAsyncCalls).toHaveLength(0) + expect(notifier.getPendingParentWakes().has("parent-internal-tail-user-race")).toBe(true) } finally { Date.now = originalDateNow notifier.shutdown()