@alma-harness/schedule
v0.12.0
Published
Alma's clock: what is due, and the tick an external scheduler calls to run it — no process kept alive.
Readme
@alma-harness/schedule
The clock of Alma, as a tick: what is due, and the call an external scheduler makes to run it. No process is kept alive (spec: clock-tick).
Status: pre-1.0. The API is still moving; see the roadmap for where it stands.
What it owns
createSchedule({ routines, runs, runner })—tick({ now? })reads the routine store, decides what is due, runs it through the routine runner with the FIRE's instant as the run id, and reports{ at, due, runs, errors }.plan(now?)answers without running.runnermay be a resolver,(routine) => RoutineRunner, for a deployment with an agent per tenant —listcrosses every scope. A routine that cannot be planned (a zone typo, a bad cron) is one entry inerrors; the others run.register/cancel— register THROUGH the schedule: it asks the runner to validate and reads the schedule once, so what the tick could not run is refused at registration, not at 8h with nobody watching.- What is due: the latest fire at or before now — a cron's previous
occurrence in its
tz(UTC when absent),everyanchored on the routine's registration,atonce — when it is later than the routine's last run. A job routine whose submission is still open is collected on every tick. Missed fires are not replayed: after downtime a daily routine runs once. latestFire,nextFire,validateSchedule— pure schedule calculations.nextFire(schedule, registeredAt, after)returns the first ISO occurrence strictly after the cursor and at or after registration, orundefinedwhen exhausted. Intervals stay anchored on registration. Hosts may enqueue an external wake-up at that time, then revalidate current configuration and use native planning/claims on wake. This helper grants no execution authority; hosts must also arrange wake-ups for missed-fire recovery and pending native batch collection.createGovernedConversationRoutineRunnerruns a triggered conversation;createGovernedBatchRoutineRunnersubmits and collects background batches. Both implementRoutineRunner, claim before work, enforce the routine's narrowed cap and daily ceiling, and make at most one delivery attempt per retained fire. The legacycreateRoutineRunnerandRoutineRunnerConfigare removed (spec: remove-legacy-routine-runner).
Wiring
One endpoint the scheduler calls on a cadence. Cloud Scheduler every minute against a Cloud Run service at zero minimum instances is the shape it was written for; anything that can call a URL on a cron is the clock.
import { createSchedule } from "@alma-harness/schedule";
const schedule = createSchedule({ routines, runs, runner: (routine) => runnerFor(routine.scope) });
await schedule.register(briefing);
// Node's http, Hono, Express — the product's choice. A shared secret, or
// the platform's OIDC, decides who may tick.
app.post("/tick", async (req, res) => {
if (req.headers["x-tick-secret"] !== process.env.TICK_SECRET) return res.status(401).end();
res.json(await schedule.tick());
});Claims and recovery
The governed runners require atomic RoutineRunStore.claim. A retained
(org, uid, routineId, runId) has one execution owner across instances,
including batch submission and collection. Pending tick plans carry
collectRunId, which custom runner wrappers must forward: it collects only
that existing run, never a new submission if the plan becomes stale.
Losing callers return its current
record; running can mean active work or an interrupted execution. A turn
may take tens of seconds, so the host request timeout must allow it.
A claim is persisted before work starts. Before calling the sink, the runner
also records the known session, turn, cost and delivery hash. If a process
dies or persistence fails, running is never expired or automatically
retried, even when an execution deadline passes. This deliberately
favors avoiding duplicates over automatic recovery. Sink errors become
failed and are also not automatically retried: an error may follow a
successful external action.
Reconcile unresolved runs against the session, provider batch and destination
before recording a final outcome through record. Age alone cannot prove
that the original worker has stopped or that a delivery did not happen.
Recording a final outcome does not resend that run. Any replacement action
requires an explicit operator decision after reconciliation; do not delete
claims to enable retries. External exactly-once delivery would require an
idempotent destination and is not guaranteed here.
Text suppression compares only with the last completed delivery. It does not
serialize different fire ids or enforce a global once-ever rule for equal
text. Daily ceilings likewise do not reserve capacity atomically across
different fire ids. Retention preserves running and submitted; removing
completed records ends their replay protection. Use a durable store in
production; the in-memory reference survives only its process.
Upgrade: drain old runners, run migrateRoutineRunStore to expand the
outcome constraint (existing records are preserved), and deploy matching
core, schedule and Postgres packages. Custom stores must implement the atomic
claim contract and callers must accept running. Run the shared
describeRoutineRunStoreContract against each custom backend. Do not mix old
runners that write only after delivery with the new claim protocol.
What it must never do
- Keep a timer. The clock is outside; this package only answers it.
- Replay missed fires, or run routines in parallel.
- Hold a vendor scheduler's SDK. The tick IS the adapter.
Financial warning policy
A routine can use budget: { perTurnUsd: { usd: 0.5, onExceeded: "warn" } }.
Turn routines require a matching warn policy and an amount no greater than
the host threshold (or no per-turn cap on the host); validation rejects widening
before claiming a run. A trusted conversation factory composes the exact narrowed
caps. Numeric routine caps still block.
Known crossings are persisted in RoutineRun.capsCrossed before delivery and
forwarded to the sink. Both per-run and persistent batch warnings come from
original governed receipts. A replay returns the metadata
without another call, charge or delivery. Deduplicate host alerts by scope, run
and cap. Frequency and duplicate guards still apply.
Run migrateRoutineRunStore before deploying these matching core, loop, schedule
and Postgres packages; it adds caps_crossed idempotently to existing rows.
Documentation
Docs index · Architecture §8 · Routine runner spec
Apache-2.0
Governed batch routines
createGovernedBatchRoutineRunner implements RoutineRunner for job routines,
so pass it directly to createSchedule. Configure a GovernedBatchConfig, host
caps, runs, sinks, a synchronous metadata-only plan, runTimeoutMs,
submissionDeadlineMs and onBackgroundError. The plan returns model/prices,
policy/price/output versions, controls, consumers, resultRetentionMs and lazy
loadRequest. The request must match the plan at batch tier without tools; its
messages are replaced with the original routine goal. Context loads only after
all governed reservations and audit. The routine per-run cap narrows host policy.
Keep batch.configRevision immutable and change it whenever plan, provider account,
system/output policy or sink resolution changes. Pending collection requires the
original revision and full routine definition. governedRoutineBatchKey(scope,
routineId, runId) exposes correlation for accounting-only repair through the batch
runner, including when delivery remains unresolved.
A scoped collection may repeat GET and repair finance. It never writes waiting metadata over a completed run. Only the permanent submitted-to-running claim winner may attempt delivery; interrupted submission or delivery is not retried. Retain unresolved records and reconcile externally. Read output again before egress to respect erasure; concurrent erasure during external delivery remains a host boundary. Warnings come from governed financial receipts and retain the answer. Closed failure reasons never contain raw provider or sink errors. Dedupe compares only the last successful text hash; duplicate replies retain their cost. Distinct fire ceilings remain non-atomic. Governed conversational routines are not part of this runner.
Governed conversational routines
createGovernedConversationRoutineRunner handles turn routines through a trusted
synchronous conversation(caps) factory returning createConversationRunner with
those exact narrowed caps. Supply matching policyVersion, configRevision,
resultContractVersion, resultRetentionMs, maxCalls, rootDeadlineMs,
runTimeoutMs, host caps, runs, sinks and onBackgroundError. Configuration
revision covers the factory's provider account, prompts, prices, tools/profiles,
result policy and sink mapping. The factory composes configuration only; it cannot
read context or perform effects. Ordinary mutable data is copied by each runner;
store, factory and sink implementations are trusted host capabilities.
The permanent routine claim precedes factory and conversation invocation. The
conversation's admission/root/output reservations precede the lazy routine goal and
all context/effects. An absent profile grants no unattended tools. Main and child
costs come only from governed receipts; warnings accompany the single sink attempt.
governedRoutineRootKey(scope, routineId, runId) exposes accounting correlation.
Uncertain conversations and late completions retain routine ownership for explicit reconciliation. Replaying a root after routine metadata loss does not grant delivery. Final content is rechecked after writing delivery metadata and immediately before sink access. Interrupted, failed or unacknowledged delivery never retries itself. Register all conversation result namespaces and sessions in erasure/retention; an external destination and concurrent egress still require host reconciliation.
The executable compositions live in the schedule tests on memory/PostgreSQL and in Prumo P1. Session/root IDs use governed hashes for correlation. Invalid routine data can throw synchronously before admission; operational errors expose fixed metadata and do not release uncertain work.
Canonical routine invocation
The conversational adapter now invokes ConversationRunner.runTurn with the
existing explicit descriptor (spec: canonical-routine-adoption). The legacy run
alias is not called. A canonical not_admitted/routing_unavailable response
finishes the fire as refused, with zero cost and no conversation root. Its
metadata replay does not retry that fire; repaired configuration can serve a new
fire. Uncertain invocation, lost refusal acknowledgement and interrupted delivery
still cannot grant new execution or sink authority.
Prumo P1 now composes both governed routine adapters over the canonical runtime's financial stores and spec 128's separate batch-result namespace. Its clock/inbox remain local fixtures; no process, real recipient or production scheduler is added.
