Skip to content

Commit 3be159c

Browse files
endolinbotclaude
andcommitted
feat(daemon): interval-scheduler formula (endoclaw-timer Phase 1 remainder)
Graduate the @endo/genie interval-scheduler prototype (packages/genie/src/interval/) into @endo/daemon as a first-class `interval-scheduler` formula, so an agent can hold a scheduled-interval (heartbeat) capability. This is the "Phase 1 remainder" of the endoclaw-timer design: formula type, extractDeps edge, and maker-table entry. - src/interval-scheduler.js: SES-safe port of the genie scheduler. Uses the daemon's filePowers (write-then-rename) for per-interval persistence instead of node:fs, an injected id generator instead of node:crypto, and injectable setTimeout/clearTimeout/now so the logic is testable against a deterministic clock. Start-to-start scheduling, resolve/reschedule with exponential backoff, tick-timeout auto-resolve, host limits (maxActive/minPeriodMs), pause/resume/revoke, and startup recovery with missed-tick coalescing all carry over. - formula-type.js / types.d.ts: register the `interval-scheduler` type, its formula record ({ agent, maxActive, minPeriodMs, paused }), and the scheduler powers/facet/entry typedefs. - daemon.js: extractLabeledDeps case (strong `agent` GC edge), the maker-table entry (per-formula persistence dir keyed by formula number, context.thisDiesIfThatDies(agent) + onCancel disarm), and formulateIntervalScheduler. - host.js / interfaces.js: a lean `makeIntervalScheduler(petName, opts?)` host command + HostInterface guard so an agent can obtain and hold the capability end-to-end. Tick delivery as daemon mail messages (with a TickResponse exo), the scheduler's own handle, the proper IntervalScheduler/IntervalControl facet split on the host method, and CLI commands remain later phases (Phase 2 / Phase 4) of the design. Tests: packages/daemon/test/interval-scheduler.test.js (7 cases: create/persist/list/tick, limits, cancel, pause/resume, revoke, startup recovery) plus the formula-type registry list. tsc + eslint clean. Design: journal plan/designs/endo-but-for-bots/endoclaw-timer.md Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1132289 commit 3be159c

8 files changed

Lines changed: 1208 additions & 1 deletion

File tree

packages/daemon/src/daemon.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ import {
8282
readableTreeHelp,
8383
} from './help-text.js';
8484
import { getMountBacking, lineageOf, makeMount } from './mount.js';
85+
import {
86+
makeIntervalScheduler,
87+
DEFAULT_MAX_ACTIVE,
88+
DEFAULT_MIN_PERIOD_MS,
89+
} from './interval-scheduler.js';
8590

8691
// Sorted:
8792
import {
@@ -801,6 +806,11 @@ const makeDaemonCore = async (
801806
['hostAgent', formula.hostAgent],
802807
['hostHandle', formula.hostHandle],
803808
];
809+
case 'interval-scheduler':
810+
// Strong edge: the scheduler (and all its persisted intervals) is
811+
// collected when its owning agent is. Phase 2 adds the scheduler's
812+
// own `handle` here once ticks are delivered by mail.
813+
return [['agent', formula.agent]];
804814
default:
805815
return [];
806816
}
@@ -3905,6 +3915,53 @@ const makeDaemonCore = async (
39053915
`Timer "${timerLabel || 'timer'}" firing every ${interval}ms. Ticks: ${tickCount}`,
39063916
});
39073917
},
3918+
'interval-scheduler': async (formula, context, _id, formulaNumber) => {
3919+
const {
3920+
agent: agentId,
3921+
maxActive,
3922+
minPeriodMs,
3923+
paused,
3924+
} = formula;
3925+
// One directory per scheduler instance (keyed by formula number),
3926+
// holding one JSON file per interval — mirroring the pet-store
3927+
// persistence pattern (endoclaw-timer design § Persistence).
3928+
const persistDir = filePowers.joinPath(
3929+
persistencePowers.statePath,
3930+
'interval-scheduler',
3931+
/** @type {string} */ (formulaNumber),
3932+
'intervals',
3933+
);
3934+
const { scheduler, schedulerControl, disarmAll } =
3935+
await makeIntervalScheduler({
3936+
filePowers,
3937+
persistDir,
3938+
// A short, unguessable id per interval entry.
3939+
makeId: async () => (await randomHex256()).slice(0, 16),
3940+
maxActive,
3941+
minPeriodMs,
3942+
paused,
3943+
});
3944+
// Die with the owning agent, and clear all timers on cancellation so
3945+
// no orphan interval keeps ticking after GC.
3946+
context.thisDiesIfThatDies(agentId);
3947+
context.onCancel(() => disarmAll());
3948+
// Phase 1 returns a single capability exposing both the agent-facing
3949+
// scheduler methods and the host-facing control methods. Phase 4 splits
3950+
// these into the `IntervalScheduler` / `IntervalControl` facet pair the
3951+
// host method returns.
3952+
return Far('IntervalScheduler', {
3953+
makeInterval: (label, periodMs, opts) =>
3954+
scheduler.makeInterval(label, periodMs, opts),
3955+
list: () => scheduler.list(),
3956+
help: () => scheduler.help(),
3957+
setMaxActive: n => schedulerControl.setMaxActive(n),
3958+
setMinPeriodMs: ms => schedulerControl.setMinPeriodMs(ms),
3959+
pause: () => schedulerControl.pause(),
3960+
resume: () => schedulerControl.resume(),
3961+
revoke: () => schedulerControl.revoke(),
3962+
listAll: () => schedulerControl.listAll(),
3963+
});
3964+
},
39083965
channel: async (formula, context, id) => {
39093966
const {
39103967
handle: handleId,
@@ -4512,6 +4569,46 @@ const makeDaemonCore = async (
45124569
});
45134570
};
45144571

4572+
/**
4573+
* Formulate an `interval-scheduler` bound to an agent, with host-set limits.
4574+
*
4575+
* @param {FormulaIdentifier} agentId - the agent the scheduler is bound to.
4576+
* @param {{ maxActive?: number, minPeriodMs?: number } | undefined} options
4577+
* @param {import('./types.js').DeferredTasks<import('./types.js').IntervalSchedulerDeferredTaskParams>} deferredTasks
4578+
*/
4579+
const formulateIntervalScheduler = async (
4580+
agentId,
4581+
options,
4582+
deferredTasks,
4583+
) => {
4584+
const {
4585+
maxActive = DEFAULT_MAX_ACTIVE,
4586+
minPeriodMs = DEFAULT_MIN_PERIOD_MS,
4587+
} = options ?? {};
4588+
return withFormulaGraphLock(async () => {
4589+
const intervalSchedulerNumber = /** @type {FormulaNumber} */ (
4590+
await randomHex256()
4591+
);
4592+
const intervalSchedulerId = formatId({
4593+
number: intervalSchedulerNumber,
4594+
node: localNodeNumber,
4595+
});
4596+
4597+
await deferredTasks.execute({ intervalSchedulerId });
4598+
4599+
/** @type {import('./types.js').IntervalSchedulerFormula} */
4600+
const formula = harden({
4601+
type: /** @type {const} */ ('interval-scheduler'),
4602+
agent: agentId,
4603+
maxActive,
4604+
minPeriodMs,
4605+
paused: false,
4606+
});
4607+
4608+
return formulate(intervalSchedulerNumber, formula);
4609+
});
4610+
};
4611+
45154612
/**
45164613
* Unlike other formulate functions, formulateNumberedHandle *only* writes a
45174614
* formula to the formula graph and does not attempt to incarnate it.
@@ -6519,6 +6616,7 @@ const makeDaemonCore = async (
65196616
getFormulaForId,
65206617
formulateChannel,
65216618
formulateTimer,
6619+
formulateIntervalScheduler,
65226620
makeMailbox,
65236621
makeDirectoryNode,
65246622
localNodeNumber,

packages/daemon/src/formula-type.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const formulaTypes = new Set([
1414
'guest',
1515
'handle',
1616
'host',
17+
'interval-scheduler',
1718
'invitation',
1819
'known-peers-store',
1920
'least-authority',

packages/daemon/src/host.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ const normalizeHostOrGuestOptions = opts => {
108108
* @param {DaemonCore['getPeerIdForNodeIdentifier']} args.getPeerIdForNodeIdentifier
109109
* @param {DaemonCore['formulateChannel']} args.formulateChannel
110110
* @param {DaemonCore['formulateTimer']} args.formulateTimer
111+
* @param {DaemonCore['formulateIntervalScheduler']} args.formulateIntervalScheduler
111112
* @param {DaemonCore['getAllNetworkAddresses']} args.getAllNetworkAddresses
112113
* @param {DaemonCore['getTypeForId']} args.getTypeForId
113114
* @param {DaemonCore['getFormulaForId']} args.getFormulaForId
@@ -152,6 +153,7 @@ export const makeHostMaker = ({
152153
getPeerIdForNodeIdentifier,
153154
formulateChannel,
154155
formulateTimer,
156+
formulateIntervalScheduler,
155157
getAllNetworkAddresses,
156158
getTypeForId,
157159
getFormulaForId,
@@ -1308,6 +1310,27 @@ export const makeHostMaker = ({
13081310
return value;
13091311
};
13101312

1313+
/**
1314+
* Create an interval scheduler bound to this agent and store it under the
1315+
* given pet name. The returned capability lets the agent create and manage
1316+
* periodic wakeup intervals (the EndoClaw heartbeat facility). Tick
1317+
* delivery as mail messages is a later phase of the endoclaw-timer design.
1318+
*
1319+
* @param {PetName} petName - Pet name to store the scheduler under.
1320+
* @param {{ maxActive?: number, minPeriodMs?: number }} [options] - Host
1321+
* limits: maximum active intervals and minimum period.
1322+
*/
1323+
const makeIntervalSchedulerCmd = async (petName, options) => {
1324+
assertPetName(petName);
1325+
/** @type {DeferredTasks<import('./types.js').IntervalSchedulerDeferredTaskParams>} */
1326+
const tasks = makeDeferredTasks();
1327+
tasks.push(identifiers =>
1328+
petStore.storeIdentifier(petName, identifiers.intervalSchedulerId),
1329+
);
1330+
const { value } = await formulateIntervalScheduler(hostId, options, tasks);
1331+
return value;
1332+
};
1333+
13111334
/**
13121335
* Create a new channel and store it under the given pet name.
13131336
* @param {NameOrPath} petName - Pet name or path to store the channel under.
@@ -1931,6 +1954,7 @@ export const makeHostMaker = ({
19311954
deliver,
19321955
makeChannel: makeChannelCmd,
19331956
makeTimer: makeTimerCmd,
1957+
makeIntervalScheduler: makeIntervalSchedulerCmd,
19341958
invite,
19351959
accept,
19361960
endow,

packages/daemon/src/interfaces.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getInfoMethodGuard,
1313
} from '@endo/platform/fs/lite';
1414
import {
15+
NameShape,
1516
NamePathShape,
1617
NameOrPathShape,
1718
NamesOrPathsShape,
@@ -369,6 +370,10 @@ export const HostInterface = M.interface('EndoHost', {
369370
makeTimer: M.call(NameOrPathShape, M.number())
370371
.optional(M.string())
371372
.returns(M.promise()),
373+
// Create an interval scheduler (EndoClaw heartbeat facility)
374+
makeIntervalScheduler: M.call(NameShape)
375+
.optional(M.record())
376+
.returns(M.promise()),
372377
// Cancel a value
373378
cancel: M.call(NameOrPathShape).optional(M.error()).returns(M.promise()),
374379
// Get the greeter

0 commit comments

Comments
 (0)