@razroo/iso-orchestrator
v0.2.0
Published
Durable workflow primitives for AI-agent harnesses: resumable steps, keyed mutexes, and bounded parallel fan-out with file-backed state.
Downloads
695
Maintainers
Readme
@razroo/iso-orchestrator
Durable workflow primitives for AI-agent harnesses.
This package is for the layer above a single agent session. An agent runtime
already knows how to think, call tools, and emit text. @razroo/iso-orchestrator
adds the deterministic parts that should not live only in prompt prose:
- resumable, idempotent
step()execution with file-backed records - bounded parallel fan-out with
forEach(..., { maxParallel }) - keyed mutexes so "same entity" work never runs concurrently
- worker heartbeats plus renewable leases for liveness / ownership tracking
- append-only-ish workflow events and durable state snapshots on local disk
It is intentionally generic. There is no built-in "dispatch a Codex worker" or "spawn an OpenCode task" primitive yet. Domain packages bring their own adapter for that part and use this package to enforce invariants around it.
Install
npm install @razroo/iso-orchestratorWhat it stores
By default the package writes under .iso-orchestrator/ in the current
working directory:
.iso-orchestrator/
workflows/
my-flow-<hash>.json
locks/
my-flow-<hash>/
record.lock/
mutex/
company-role-<hash>.lock/The workflow record contains:
- current workflow status (
idle,running,completed,failed) - durable JSON state
- step attempt counts and cached step results
- optional heartbeat snapshots and lease ownership records
- event history (
workflow.running,step.started,step.completed, ...)
Quick example
import { runWorkflow } from '@razroo/iso-orchestrator';
const { value, record } = await runWorkflow(
{
workflowId: 'apply-batch-2026-04-25',
dir: '.jobforge-runtime',
initialState: { applied: 0, skipped: 0 },
},
async (workflow) => {
await workflow.step('cleanup-geometra', async () => ({ ok: true }));
const jobs = [
{ company: 'Anthropic', role: 'Staff Engineer' },
{ company: 'OpenAI', role: 'Member of Technical Staff' },
{ company: 'Anthropic', role: 'Staff Engineer' },
];
const summary = await workflow.forEach(
jobs,
async (job) => {
return workflow.step(
`apply:${job.company}:${job.role}`,
async () => {
// Your own task-dispatch adapter goes here.
return { outcome: 'APPLIED', company: job.company, role: job.role };
},
{ idempotencyKey: `${job.company}:${job.role}` },
);
},
{
maxParallel: 2,
mutexKey: (job) => `${job.company}:${job.role}`,
},
);
await workflow.updateState((state) => ({
...state,
applied: summary.fulfilled,
skipped: summary.rejected,
}));
return summary;
},
);
console.log(value.fulfilled, record.status);API
runWorkflow(options, fn)
Creates or re-opens a workflow record, marks it running, executes fn, and
marks the workflow completed or failed.
Options:
workflowId: durable identifier for the logical workflowinitialState: JSON value written only when the workflow record does not exist yetdir: optional storage root (defaults to.iso-orchestrator)now: optional clock injection for tests
openWorkflow(options)
Opens the workflow context without automatically running a top-level callback. Useful when you want finer control over lifecycle or only need inspection.
workflow.step(name, fn, options?)
Runs one load-bearing step and persists its result.
- If the same step already completed, the cached JSON result is returned.
idempotencyKeylets one logical step name be reused safely across runs.retrycan be a number (3) or{ attempts, shouldRetry }.
Results must be JSON-serializable because they are persisted in the record.
workflow.withMutex(key, fn, options?)
Runs fn while holding a process-safe filesystem lock for key. Useful when
two parallel tasks must never touch the same entity at once.
Options:
timeoutMs: how long to wait for the lockpollMs: wait interval while the lock is held elsewherestaleAfterMs: optional stale-lock eviction threshold
workflow.heartbeat(key, detail?)
Persists the latest heartbeat for a named worker or phase. This is useful when you want external inspectors to tell whether a background task is still progressing even if it is not currently holding a mutex.
workflow.touchLease(key, { holder, ttlMs, detail? })
Acquires or renews a renewable lease for key.
- a missing, expired, or released lease is acquired
- the current holder can renew in place
- a different holder gets a
WorkflowLeaseConflictErrorwhile the lease is active
Use this for "one worker owns this slot until its heartbeat expires" semantics without introducing a harness-specific queue.
workflow.releaseLease(key, holder?)
Marks a lease released. Passing holder is optional but recommended so a
worker cannot accidentally release another worker's active lease.
workflow.forEach(items, fn, options?)
Bounded fan-out over a list.
maxParallelcontrols concurrencymutexKey(item)optionally serializes related itemsstopOnErrordefaults totrue
Returns a summary with results, fulfilled, and rejected.
workflow.updateState(updater)
Replaces the durable JSON state. Accepts either a full state object or a functional updater.
workflow.appendEvent(input) / workflow.getRecord()
Low-level helpers for custom lifecycle tracking and inspection.
Scope boundary
This package does not try to be Temporal.
What it does:
- local durable records
- load-bearing step caching
- process-safe local mutexes
- simple bounded concurrency
What it does not do yet:
- distributed queues
- remote workers
- harness-specific task dispatch APIs
- cron / scheduling
That narrower scope is deliberate. The goal is to let packages like JobForge stop expressing orchestration invariants only in prompt prose or Bash without forcing a heavyweight control plane.
