ある朝、夜間に走らせていたエージェントのログを開いて、手が止まりました。深夜2時台に始まった処理が「4時間14分経過したのでタイムアウトしました」と自らを打ち切っていたのです。実際の作業内容は10分ほどで終わるはずのものでした。原因を追ってようやく腑に落ちたのは、途中でマシンがスリープに入り、復帰後に壁時計(現在時刻)の差分をそのまま「経過時間」として扱っていたことでした。処理は健全だったのに、時計の読み方だけが間違っていたのです。
個人開発でアプリを作りながら、複数の保守作業を夜間のスケジュール実行にまとめて任せていると、この種の「時計に起因する誤動作」は避けて通れません。エージェントに長い時間を預けるほど、時刻をどう測るかという地味な設計が、安心して任せられるかどうかを静かに左右します。ここでは、単調時計(monotonic clock)と壁時計(wall clock)を意図的に分けて扱う小さな設計を、動くコードと決定表を交えて整理してまいります。
なぜ壁時計での経過時間計算は壊れるのか
まず、多くのコードが暗黙に採用している書き方を見てみます。
const start = Date.now();
await doWork();
const elapsedMs = Date.now() - start;
if (elapsedMs > 30_000) {
throw new Error("処理が30秒を超えました");
}
一見すると素直で、短時間の処理ではまず問題になりません。しかし Date.now() が返すのは壁時計、つまり「いま何時か」という人間向けの時刻です。壁時計は、私たちの都合とは無関係に動きます。
具体的には、次のような場面で差分が実際の所要時間からずれます。
| 事象 | 壁時計への影響 | 経過時間計算で起きること |
| NTPによる時刻補正 | 数百msから数秒、前後に飛ぶ | 経過が負の値になる、または急増する |
| VMやノートPCのサスペンド/復帰 | 停止していた分だけ一気に進む | 健全な処理を「タイムアウト」と誤判定 |
| 夏時間(DST)の切り替え | 1時間進む/戻る | 深夜帯の処理で±1時間の誤差 |
| 手動・同期による時刻変更 | 任意に飛ぶ | リトライ間隔やレート制限窓が破綻 |
特に厄介なのは、時刻が戻るケースです。NTPが壁時計を過去に引き戻すと、Date.now() - start が負になり、elapsedMs > 30_000 は永遠に成立しません。タイムアウトが効かないまま、エージェントが待ち続けます。逆に、サスペンドから復帰した直後は差分が跳ね上がり、まだ何も終わっていないのに打ち切りが走ります。どちらも、ログには「タイムアウト」や「無限待機」としか残らず、真因にたどり着くまで時間を溶かします。ここが最初の注意点です。
単調時計という選択肢
この問題の中心は、「所要時間の測定」に「現在時刻の表示」用の時計を流用していることにあります。所要時間には、単調時計を使うのが正解です。
単調時計は、後戻りしないことを保証された時計です。NTPが補正しても、DSTが切り替わっても、値は必ず増加方向にしか進みません。Node.js では process.hrtime.bigint() が、ブラウザや Deno では performance.now() がこれにあたります。返すのは「いつ」ではなく「起点からどれだけ進んだか」という相対量で、人間向けの時刻としては読めませんが、差分を取れば正確な所要時間になります。
const start = process.hrtime.bigint();
await doWork();
const elapsedMs = Number(process.hrtime.bigint() - start) / 1_000_000;
if (elapsedMs > 30_000) {
throw new Error("処理が30秒を超えました");
}
見た目の変化はわずかですが、意味は大きく変わりました。この elapsedMs は、マシンがスリープしていようがNTPが暴れていようが、実際に経過した実時間を表します。ただし単調時計は「起点からの相対値」なので、プロセスをまたいだり、ログに人間が読める時刻を残したりする用途には使えません。この場合は、役割分担の設計が必要になります。
役割を分ける小さな Clock 抽象
私が複数のスケジュールタスクで落ち着いた形は、時計を用途で二つに割り、明示的な抽象にまとめることでした。所要時間には単調時計、記録や次回起動の予定には壁時計、と境界を引きます。私はこの分離を強くお勧めします。
export interface Clock {
// 単調: 所要時間・タイムアウト・バックオフの計測にのみ使う
monotonicMs(): number;
// 壁時計: ログ・保存・人間向け時刻・次回起動の予定にのみ使う
wallNow(): Date;
}
export const systemClock: Clock = {
monotonicMs: () => Number(process.hrtime.bigint() / 1_000_000n),
wallNow: () => new Date(),
};
// 期限は「単調時計上の締め切り」として持つ
export function createDeadline(clock: Clock, budgetMs: number) {
const deadline = clock.monotonicMs() + budgetMs;
return {
remainingMs: () => deadline - clock.monotonicMs(),
expired: () => clock.monotonicMs() >= deadline,
};
}
締め切りを「壁時計上の何時何分」ではなく「単調時計上の到達点」として保持するのが要点です。こうすると、remainingMs() や expired() はサスペンドや時刻補正の影響を受けません。エージェントのステップ実行ループでは、次のように締め切りを引き回します。
async function runSteps(clock: Clock, steps: Step[], budgetMs: number) {
const dl = createDeadline(clock, budgetMs);
for (const step of steps) {
if (dl.expired()) {
// 記録には壁時計を使う(人間が後で読むため)
log.warn(`予算超過で中断: ${clock.wallNow().toISOString()}`);
break;
}
await step.run({ remainingMs: dl.remainingMs() });
}
}
ログに残す時刻だけは wallNow()、つまり壁時計です。ここは「いつ起きたか」を人間が読むための情報なので、単調時計の相対値では役に立ちません。二つの時計は競合するのではなく、役割で棲み分けます。
リトライのバックオフも単調時計で
タイムアウトと並んで壁時計が忍び込みやすいのが、リトライのバックオフ計算です。「前回失敗した時刻」を壁時計で覚えておき、「いまの時刻との差」で待機を判断する実装は、時刻が戻った瞬間に総崩れになります。すべてのリトライが同じ秒に殺到したり、逆に永遠に次のリトライが来なかったりします。この罠は、単調時計を基準にすれば回避できます。
async function withRetry<T>(
clock: Clock,
fn: () => Promise<T>,
opts: { maxAttempts: number; baseMs: number },
): Promise<T> {
let attempt = 0;
while (true) {
const attemptStart = clock.monotonicMs();
try {
return await fn();
} catch (err) {
attempt++;
if (attempt >= opts.maxAttempts) throw err;
// 指数バックオフ + ジッター。基準は単調時計上の経過
const spent = clock.monotonicMs() - attemptStart;
const backoff = opts.baseMs * 2 ** (attempt - 1);
const jitter = Math.random() * opts.baseMs;
const waitMs = Math.max(0, backoff + jitter - spent);
await sleep(waitMs);
}
}
}
ここでも判断の基準は monotonicMs() です。バックオフの待機時間から、その試行にすでに費やした時間(spent)を差し引くことで、失敗が速い場合も遅い場合も、次の試行までの実間隔を安定させています。レート制限の窓(例えば「60秒あたり10回」)を自前で管理する場合も同様で、窓の開始を単調時計で刻めば、時刻が飛んでも二重カウントや取りこぼしが起きません。本番運用でこの安定性は、地味ですが確かな効き目があります。
用途ごとの決定表
どの用途にどちらの時計を使うかは、一度表にしておくと迷いません。新しいコードを書くときも、レビューのときも、この一望が判断を速くしてくれます。
| 用途 | 使う時計 | 理由 |
| タイムアウト判定 | 単調 | 実際の経過時間だけが問題。時刻補正に左右されてはならない |
| リトライのバックオフ | 単調 | 待機間隔は相対量。時刻が戻ると総崩れになる |
| レート制限の窓 | 単調 | 窓の長さは相対量。二重カウント・取りこぼしを防ぐ |
| ステップ予算・締め切り | 単調 | サスペンドをまたいでも予算を守る |
| ログ・監査の時刻 | 壁時計 | 人間が「いつ」を読むための情報 |
| 次回スケジュール起動 | 壁時計 | 「毎日7時」など暦に紐づく。タイムゾーンとDSTを明示 |
| プロセスをまたぐ締め切り | 壁時計(+単調で再計算) | 単調値はプロセス間で共有できない。壁時計で渡し、受け手が単調へ換算 |
最後の行が、実務でつまずきやすい点です。単調時計の値は同一プロセス内でしか意味を持ちません。ジョブキューに「この締め切りまでに処理して」と渡すときは、壁時計の絶対時刻(できればUTC)で渡し、受け取ったワーカー側が「あと何ms残っているか」を計算して、自分の単調時計上の締め切りに換算します。渡すときと測るときで時計を切り替える、この一手間が、分散したステップ間で予算を守る鍵になります。
決定論的にテストする
Clock を抽象にした最大の実利は、テストのしやすさです。systemClock の代わりに、こちらの都合で時刻を進められる偽の時計を差し込めます。
function fakeClock(startMono = 0, startWall = "2026-07-12T10:00:00Z"): Clock & {
advance(ms: number): void;
} {
let mono = startMono;
let wall = new Date(startWall).getTime();
return {
monotonicMs: () => mono,
wallNow: () => new Date(wall),
advance(ms) { mono += ms; wall += ms; },
};
}
// テスト: サスペンドを模して単調時計だけ進めても締め切りが守られる
const clock = fakeClock();
const dl = createDeadline(clock, 30_000);
clock.advance(29_000);
console.assert(!dl.expired(), "29秒では未超過のはず");
clock.advance(2_000);
console.assert(dl.expired(), "31秒で超過するはず");
さらに踏み込むなら、advance で単調時計と壁時計を別々に動かせるようにしておくと、「壁時計だけが戻る(NTP補正)」「壁時計だけが飛ぶ(サスペンド復帰で時刻同期)」といった意地の悪い状況も再現できます。冒頭で私を悩ませた「4時間経過の誤タイムアウト」も、この形のテストなら数行で再現し、二度と壊れないよう固定できます。実時間の待機に頼るテストは遅く不安定になりがちですが、時計を注入できる設計なら、時間に関するロジックを一瞬で、かつ決定論的に検証できます。
長時間運用に効く小さな習慣
Antigravity の最近の更新では、OAuthトークンをOSのキーリングへ自動保存し、認証プロンプトの回数を減らす改善が入りました。これは、より長く途切れずにエージェントを走らせられる方向への一歩です。だからこそ、走行時間が延びるほど、時計の読み方が結果を左右する場面も増えていきます。認証で止まらなくなった処理が、今度は時計の読み違いで自らを打ち切っては本末転倒です。
導入は大がかりである必要はありません。まずは、既存コードの Date.now() を用途で棚卸しするところから始めれば十分です。「これは所要時間の測定か、それとも記録のための時刻か」と一つずつ問い、前者を単調時計へ移すだけで、サスペンドやNTPに起因する誤動作の大半は解決へ向かいます。私自身、この棚卸しを一度通しただけで、夜間タスクの原因不明な打ち切りがぴたりと止まりました。
時計は、普段は意識に上らない土台のような存在です。けれど、長い時間を機械に預けるようになると、その土台の確かさが、安心して眠れるかどうかに直結します。小さな抽象を一つ挟むだけで、時間にまつわる不確かさをコードの隅に押し込めておける。この記事が、皆さんの夜間運用を少しでも静かなものにできれば嬉しいです。お読みいただきありがとうございました。