@arki/clock for agents
For @arki/[email protected] · source docs/agent-guide.md
The long form of llms.txt. When they disagree, src/index.ts wins.
The model
- A
Clockhas three methods:now(),setTimer(callback, ms)andsleep(ms, signal?). SystemClockis the real one.MockClockis the test one: time moves only when you calladvance,advanceAsyncorsetNow.- Code that needs time takes a
Clockparameter and defaults it tonew SystemClock(). - Both clocks apply the same delay rules and return the same cancellation definition and throw the same setup
ClockErrorcodes.
Task map
Make time-dependent code testable
- Add a
clock: Clockparameter. Default it tonew SystemClock()at the edge of the app. - Replace
new Date()withclock.now()andsetTimeoutwithclock.setTimer. - In the test, pass a
MockClockand calladvance.
import type { Clock } from '@arki/clock';
import { MockClock } from '@arki/clock';
function isExpired(clock: Clock, at: Date): boolean {
return clock.now().getTime() > at.getTime();
}
const clock = new MockClock(new Date('2026-01-01T00:00:00.000Z'));
const deadline = new Date('2026-01-01T01:00:00.000Z');
if (isExpired(clock, deadline)) throw new Error('not expired yet');
clock.advance(60 * 60 * 1000 + 1);
if (!isExpired(clock, deadline)) throw new Error('should be expired');Test a retry loop that waits between tries
- Start the code under test without awaiting it.
- Call
await clock.advanceAsync(totalDelay). It lets promise callbacks run between timers, so the next timer exists before time moves on. - Await the code under test.
import { isFailure } from '@arki/failure';
import { MockClock } from '@arki/clock';
const clock = new MockClock();
async function retry(attempts: number): Promise<number> {
let tries = 0;
for (let attempt = 0; attempt < attempts; attempt += 1) {
tries += 1;
const outcome = await clock.sleep(1000 * 2 ** attempt);
if (isFailure(outcome)) throw outcome;
}
return tries;
}
const done = retry(3);
await clock.advanceAsync(7000); // 1 s + 2 s + 4 s
if ((await done) !== 3) throw new Error('expected three tries');Cancel a wait
- Pass an
AbortSignaltosleep. - Await the outcome and check
SleepCancelled.is(outcome); cancellation is returned, never rejected.
Rules that bite
advanceis synchronous. A timer that async code sets after anawaitis not there yet. UseadvanceAsync.- Do not mix
advanceAsyncwith fake timers that replacesetTimeout. It waits one real macrotask per timer. setNowfires no timers, even when it jumps past their due time.- A delay above
MAX_TIMER_MS(2^31 - 1 ms, about 24.8 days) throws. Split a longer wait. MockClock.now()returns a newDate. Changing it does not move the clock.
Errors
| Code | Meaning | What to do |
|---|---|---|
ARKI_CLOCK_E001 | advance or advanceAsync got a negative or non-finite step. | Pass zero or more. Use setNow to go back. |
ARKI_CLOCK_E002 | A delay is negative, not finite, or above MAX_TIMER_MS. | Pass a valid delay. Split long waits. |
clock.sleep_cancelled | The signal aborted a sleep; private cause is signal.reason. | Handle the returned outcome. |
Verify
bun run test
bun run test:docs
bun ../../tooling/library/src/llms.ts --checkSleep waits for asynchronous cleanup returned by a provider before settling. Asynchronous listener registration rejection becomes a defect while sleep is pending; a rejection after completion is observed without changing the completed outcome. Owned sleep provider registration and cleanup defects return canonical UnexpectedDefect from @arki/failure. SLEEP_FAILURES contains exactly SleepCancelled and UnexpectedDefect; private causes are excluded from serialization.