Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions changelog.d/tune-out-long-timers.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
- A tune-out's `durationSeconds` or `cadenceSeconds` longer than about 24.8
days (2^31 - 1 ms, the longest delay one Node timer holds) now holds.
Before, Node fired the timer almost at once, so such a tune-out ended as
"duration elapsed" moments after it began, waking the resident with its
backlog, and such a cadence re-armed about every millisecond, giving the
subconscious a turn as soon as anything was diverted. Both timers now
wait in steps no longer than one timer. The cadence counts its period on
those timers, as it did before, so setting the wall clock doesn't move
it. The end time reads the wall clock against the stored end at each
step, so a host that runs through a long tune-out and one that restarts
during it aim at the same end.
- A tune-out that ends in the channel registry without a cancel (the send
path and the close tool set the channel's state there directly) no
longer leaves its timers to act on the channel's next tune-out. Before,
when the next tune-out had no `durationSeconds`, the old end time ended
it as "duration elapsed", and a wake still in its 2-second coalescing
window counted against it and gave the subconscious a turn.
- `tune_out` refuses a `durationSeconds`, `cadenceSeconds`, `backlogCap` or
`maxWakes` that gives no finite number, such as `"abc"`, and a duration
whose end time is too far off to store, naming the option and saying
nothing was tuned out, before anything changes. Before, the store's write
threw after the channel had been marked tuned out in memory, so the call
failed and left the channel diverted with no cadence or end timer until
the next restart. A numeric string such as `"3600"` is still read as its
number.
- A `null` `durationSeconds` now means the tune-out lasts until cancelled,
as leaving it out does. Before, it gave a tune-out of 60 seconds, the
shortest duration.
44 changes: 44 additions & 0 deletions src/deadline-timer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/** The longest delay one setTimeout holds (2^31 - 1 ms, about 24.8 days).
* Node fires a longer one almost at once, with a TimeoutOverflowWarning. */
const MAX_TIMER_MS = 2_147_483_647;

type Hold = (timer: ReturnType<typeof setTimeout>) => void;

/**
* Call `fire` at `deadlineMs` (epoch ms) by the wall clock, through timers no
* longer than one holds, so a deadline weeks away fires then rather than at
* once. When a step ends, it reads the wall clock against the deadline and
* arms the next for what's left, so the steps' own delays don't add up, and a
* process that restarts and arms the same stored deadline aims at the same
* time. A step's own wait runs on Node's monotonic clock, so a change to the
* wall clock during a step is seen when that step ends. `hold` gets each
* step's timer: clearing the latest one stops the rest.
*/
export function armAt(deadlineMs: number, hold: Hold, fire: () => void): void {
const arm = (): void => {
// A deadline that has just passed arms a 0 ms step rather than a negative
// one, which Node 23 and later warn about.
hold(setTimeout(step, Math.min(Math.max(0, deadlineMs - Date.now()), MAX_TIMER_MS)));
};
const step = (): void => {
if (deadlineMs - Date.now() > 0) arm();
else fire();
};
arm();
}

/**
* Call `fire` after `delayMs`, counted on timers no longer than one holds, so
* a delay weeks long fires then rather than at once. The timers count the
* time, so setting the wall clock doesn't move it. `hold` gets each step's
* timer: clearing the latest one stops the rest.
*/
export function armAfter(delayMs: number, hold: Hold, fire: () => void): void {
let left = delayMs;
const arm = (): void => {
const step = Math.min(left, MAX_TIMER_MS);
left -= step;
hold(setTimeout(left > 0 ? arm : fire, step));
};
arm();
}
93 changes: 70 additions & 23 deletions src/tune-out/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { StoredMessage } from '@animalabs/context-manager';
import type { ChannelRegistry, TuneOutParams } from '../mcpl/channel-registry.js';
import type { McplServerRegistry } from '../mcpl/server-registry.js';
import { CapabilityGrant } from '../mcpl/capability-grant.js';
import { armAfter, armAt } from '../deadline-timer.js';

/** Defaults for tune_out tool params. */
export const TUNE_OUT_DEFAULTS = {
Expand Down Expand Up @@ -64,7 +65,9 @@ export interface TuneOutFrameworkHooks {

export class TuneOutCoordinator {
private cadenceTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** Duration-expiry timers (params.expiresAtMs); cleared on any cancel. */
/** Each duration's pending expiry step (params.expiresAtMs). stop clears
* it, cancel does while the registry still holds the tune-out, and the
* channel's next enter does in any case. */
private expiryTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** Channels with a wake invocation pending inside the coalescing window. */
private pendingWakes = new Map<string, { count: number; timer: ReturnType<typeof setTimeout> }>();
Expand Down Expand Up @@ -108,11 +111,10 @@ export class TuneOutCoordinator {
const key = this.key(serverId, channelId);
const existing = this.expiryTimers.get(key);
if (existing) clearTimeout(existing);
const remaining = Math.max(0, params.expiresAtMs - Date.now());
this.expiryTimers.set(key, setTimeout(() => {
armAt(params.expiresAtMs, (timer) => { this.expiryTimers.set(key, timer); }, () => {
this.expiryTimers.delete(key);
this.cancel(serverId, channelId, 'duration', 'duration elapsed');
}, remaining));
});
}

private listActive(): Array<{ serverId: string; channelId: string; params: TuneOutParams }> {
Expand Down Expand Up @@ -145,16 +147,57 @@ export class TuneOutCoordinator {
if (this.channelRegistry.getTuneOutState(serverId, channelId)) {
return { ok: false, error: `channel ${channelId} is already tuned out (cancel first to change params)` };
}
// A tool's input reaches here as the model wrote it, and the store can't
// keep a NaN or an Infinity: its append would throw after the registry
// had marked the channel tuned out. So each option is read as a number
// before anything changes, a numeric string included, as the sleep tool
// reads its seconds; null counts as left out. A value that gives no
// finite number is refused.
const leftOut = {
durationSeconds: 'to stay tuned out until you cancel',
cadenceSeconds: `for the default, ${TUNE_OUT_DEFAULTS.cadenceSeconds}`,
backlogCap: `for the default, ${TUNE_OUT_DEFAULTS.backlogCap}`,
maxWakes: `for the default, ${TUNE_OUT_DEFAULTS.maxWakes}`,
} as const;
const given: Partial<Record<keyof typeof leftOut, number>> = {};
for (const name of Object.keys(leftOut) as Array<keyof typeof leftOut>) {
const value: unknown = opts[name];
if (value == null) continue;
const n = Number(value);
if (!Number.isFinite(n)) {
const got = typeof value === 'number'
? String(value)
: `${typeof value === 'string' ? 'the string' : 'the value'} ${JSON.stringify(value)}`;
return {
ok: false,
error: `Nothing was tuned out: ${name} must be a finite number${name.endsWith('Seconds') ? ' of seconds' : ''}, `
+ `and it was ${got}. Leave it out ${leftOut[name]}.`,
};
}
given[name] = n;
}
const expiresAtMs = given.durationSeconds === undefined
? undefined
: Date.now() + Math.max(60, given.durationSeconds) * 1000;
if (expiresAtMs !== undefined && !Number.isFinite(expiresAtMs)) {
return {
ok: false,
error: `Nothing was tuned out: durationSeconds ${given.durationSeconds} is longer than an end time can be kept. `
+ `Leave it out ${leftOut.durationSeconds}.`,
};
}
const params: TuneOutParams = {
epochId: randomUUID(),
cadenceSeconds: Math.max(30, opts.cadenceSeconds ?? TUNE_OUT_DEFAULTS.cadenceSeconds),
backlogCap: Math.max(0, opts.backlogCap ?? TUNE_OUT_DEFAULTS.backlogCap),
maxWakes: Math.max(1, opts.maxWakes ?? TUNE_OUT_DEFAULTS.maxWakes),
cadenceSeconds: Math.max(30, given.cadenceSeconds ?? TUNE_OUT_DEFAULTS.cadenceSeconds),
backlogCap: Math.max(0, given.backlogCap ?? TUNE_OUT_DEFAULTS.backlogCap),
maxWakes: Math.max(1, given.maxWakes ?? TUNE_OUT_DEFAULTS.maxWakes),
startedAtSequence: this.hooks.currentSequence(),
...(opts.durationSeconds !== undefined
? { expiresAtMs: Date.now() + Math.max(60, opts.durationSeconds) * 1000 }
: {}),
...(expiresAtMs !== undefined ? { expiresAtMs } : {}),
};
// A tune-out can end in the registry without cancel (a send into the
// channel or a close sets its state there), leaving its timers armed:
// clear them, so none of them acts on this one.
this.clearTimers(serverId, channelId);
this.channelRegistry.enterTuneOut(serverId, channelId, params, source);
this.armExpiry(serverId, channelId, params);

Expand Down Expand Up @@ -190,17 +233,7 @@ export class TuneOutCoordinator {
if (!ended) {
return { ok: false, error: `channel ${channelId} is not tuned out` };
}
this.stopCadence(serverId, channelId);
const expiry = this.expiryTimers.get(this.key(serverId, channelId));
if (expiry) {
clearTimeout(expiry);
this.expiryTimers.delete(this.key(serverId, channelId));
}
const pending = this.pendingWakes.get(this.key(serverId, channelId));
if (pending) {
clearTimeout(pending.timer);
this.pendingWakes.delete(this.key(serverId, channelId));
}
this.clearTimers(serverId, channelId);
this.divertedSinceInvocation.delete(this.key(serverId, channelId));

// Backlog: every message stamped with this epoch, oldest first.
Expand Down Expand Up @@ -342,6 +375,7 @@ export class TuneOutCoordinator {
private startCadence(serverId: string, channelId: string, cadenceSeconds: number): void {
this.stopCadence(serverId, channelId);
const key = this.key(serverId, channelId);
const hold = (timer: ReturnType<typeof setTimeout>): void => { this.cadenceTimers.set(key, timer); };
const tick = () => {
const state = this.channelRegistry.getTuneOutState(serverId, channelId);
if (!state) return; // cancelled meanwhile
Expand All @@ -350,9 +384,22 @@ export class TuneOutCoordinator {
if (diverted > 0) {
this.invokeSubconscious(serverId, channelId, 'cadence', diverted);
}
this.cadenceTimers.set(key, setTimeout(tick, cadenceSeconds * 1000));
armAfter(cadenceSeconds * 1000, hold, tick);
};
this.cadenceTimers.set(key, setTimeout(tick, cadenceSeconds * 1000));
armAfter(cadenceSeconds * 1000, hold, tick);
}

/** Clear a channel's tune-out timers: its cadence, its end time and a wake
* still coalescing. */
private clearTimers(serverId: string, channelId: string): void {
const key = this.key(serverId, channelId);
this.stopCadence(serverId, channelId);
const expiry = this.expiryTimers.get(key);
if (expiry) clearTimeout(expiry);
this.expiryTimers.delete(key);
const pending = this.pendingWakes.get(key);
if (pending) clearTimeout(pending.timer);
this.pendingWakes.delete(key);
}

private stopCadence(serverId: string, channelId: string): void {
Expand Down
Loading
Loading