mill — Effect-first orchestration runtime (v0 spec)#
Status: Draft for implementation
Scope: local CLI + SDK runtime, detached async runs, generic drivers, Effect-only core
1) Product definition#
mill is a runtime for executing TypeScript orchestration programs that spawn and coordinate AI agents.
A mill program is regular TS (sequential with await, parallel with Promise.all), with one injected global API:
mill.spawn(...)(core)- extension-contributed APIs (optional)
mill stores orchestration state and structured run events. Agent conversations remain owned by each agent tool; mill keeps sessionRef pointers.
2) Hard constraints#
- Effect is the only execution system
- No
async/awaitin core runtime modules. - No raw
Promiseconstruction. - No
try/catchcontrol flow (except inside Effect wrappers where required by external APIs).
- No
- Process execution through Effect platform abstractions
- Drivers use Effect
Command/ process services. - On Bun, these are provided by
@effect/platform-bun(Bun-backed runtime under the hood).
- Drivers use Effect
- Minimal CLI surface
- No
specortemplatesubcommands in v0.
- No
- Async-by-default runs
mill run <program.ts>returnsrunIdimmediately unless--syncis passed.
- Drivers are generic infra adapters
- No vendor-specific driver concepts in core contracts.
- Vendor specifics belong in codecs and config.
- Boundary clarity is mandatory
src/public/**/*.api.ts: user-facing Promise + interface contracts.src/internal/**,src/domain/**,src/runtime/**: Effect contracts + Schema domain models.- Internal interfaces are capability-only (method signatures), never domain shape definitions.
- The boundary must be visible in filenames and enforced via ast-grep.
- Promise bridge is explicit and singular
- Only
Runtime.runPromiseis allowed as the Effect→Promise bridge. - It is allowed only at public boundary adapters (
src/public/**, CLI entry adapters). Effect.runPromise*andRuntime.runPromiseExitare disallowed.
- Only
- No shell-string command execution
- Drivers must construct commands as argument vectors (
Command.make(cmd, ...args)). - Shell-eval patterns (
sh -lc,bash -lc, interpolated command strings) are disallowed.
- Drivers must construct commands as argument vectors (
- Environment access is centralized
process.envreads are allowed only in config/bootstrap loading modules.- Internal runtime logic receives resolved values via services/config objects.
- Time/random are injected
Date.now()andMath.random()are disallowed in runtime/domain internals.- Use injected Effect services (
Clock,Random) instead.
- Internal module boundaries are strict
- Public modules must not import from
src/internal/**directly. - Package exports expose only public API entrypoints.
- Terminal state is single-shot
- Each run/spawn emits exactly one terminal event (
complete|failed|cancelled). - Terminal states are immutable and idempotent.
3) CLI surface (v0)#
mill run <program.ts> [--json] [--sync] [--driver <name>] [--executor <name>] [--confirm=false]
mill status <runId> [--json]
mill wait <runId> --timeout <seconds> [--json]
mill watch [--run <runId>] [--channel events|io|all] [--source driver|program] [--spawn <spawnId>] [--json]
mill ls [--json] [--status <status>]
mill cancel <runId> [--json]
mill init [--global]
Discovery (for humans and agents):
mill(no subcommand): concise discovery cardmill --help: help text + authoring guidancemill --help --json: machine-readable discovery payload
No other commands in v0.
3.1 Output mode contract#
--jsonmode:stdoutis machine-readable only (JSON for single response, JSONL for streams likewatch).- human-friendly diagnostics/progress may be emitted on
stderr.
- non-
--jsonmode:- human output on
stdoutis expected.
- human output on
--jsonpayloads may includesummaryHumanfields for agent readability without breaking parsers.
4) Runtime topology#
mill program (TS)
-> executor (direct | vm)
-> engine (run lifecycle, API injection, events, persistence)
-> driver (generic)
-> agent process / remote endpoint
engine events -> watch/tui/automation
All layers are orthogonal:
- Executor = where program runs
- Driver = how spawns invoke agents
- Extension = hooks + extra API
- Observer = event consumer
5) Run model#
5.1 Async default#
mill run flow (default):
- Resolve config
- Validate program path
- Optional interactive confirmation
- Allocate
runId, create run directory, write initial metadata - Start detached worker process
- Return immediately (
runId,status=running, paths)
--sync blocks until completion (implemented as submit + wait internally).
5.2 Run state machine#
pending -> running -> complete
-> failed
-> cancelled
5.3 Storage layout#
~/.mill/
runs/
<runId>/
run.json # run metadata snapshot
events.ndjson # tier-1 structured events (append-only)
result.json # final summarized result
program.ts # copied execution input
logs/
worker.log
spawns/
<spawnId>.json # optional derived spawn summary
6) Config contract (mill.config.ts)#
import { defineConfig } from "@mill/core";
export default defineConfig({
defaultDriver: "default",
defaultExecutor: "direct",
drivers: {
default: processDriver({
command: "pi",
args: ["-p"],
codec: piCodec(),
env: {},
}),
},
executors: {
direct: directExecutor(),
vm: vmExecutor({ runtime: "docker", image: "mill-sandbox:latest" }),
},
authoring: {
instructions: "Use systemPrompt for WHO and prompt for WHAT. Prefer cheaper models for search and stronger models for synthesis.",
},
extensions: [
// optional
],
});
6.1 Config resolution order#
./mill.config.ts(cwd)- walk upward to repo root
~/.mill/config.ts- internal defaults
6.2 Environment resolution policy#
- Environment variables are read in config/bootstrap only.
- Resolved env values are normalized into config/services and passed downward.
- Runtime/domain modules must not read
process.envdirectly.
7) Discovery contract (mill --help --json)#
mill --help --json MUST include enough info for an agent to author a program without extra docs:
{
"discoveryVersion": 1,
"programApi": {
"spawnRequired": ["agent", "systemPrompt", "prompt", "model"],
"spawnOptional": [],
"resultFields": ["text", "sessionRef", "agent", "model", "driver", "exitCode", "stopReason"]
},
"drivers": {
"default": {
"description": "Local process driver",
"modelFormat": "provider/model-id",
"models": ["openai/gpt-5.3-codex", "anthropic/claude-sonnet-4-6"]
}
},
"authoring": {
"instructions": "...from config..."
},
"async": {
"submit": "mill run <program.ts> --json",
"status": "mill status <runId> --json",
"wait": "mill wait <runId> --timeout 30 --json"
}
}
8) Boundary contracts: public Promise API, internal Effect core#
Rule of thumb (strict):
- User-exposed surface: Promise-based API + interfaces are allowed.
- Everything else: Effect-first (
Effect,Stream,Layer) + Schema-defined domain types.
Concretely:
- Public boundary (
src/public/**, ambient*.d.ts):- can expose
Promise<T> - can use
interfacefor ergonomics
- can expose
- Internal/domain/runtime (
src/internal/**,src/domain/**,src/runtime/**):- no public Promise contracts
- domain shapes must be defined by
@effect/schema/Schema - no interface-based domain modelling
Effect contracts used internally:
- effects:
Effect.Effect<A, E, R> - streams:
Stream.Stream<A, E, R> - layers:
Layer.Layer<ROut, E, RIn> - queue/pubsub for event fanout
- schemas via
@effect/schema/Schema
8.1 Domain schemas (representative)#
import * as Schema from "@effect/schema/Schema";
export const RunId = Schema.String.pipe(Schema.brand("RunId"));
export type RunId = Schema.Schema.Type<typeof RunId>;
export const SpawnId = Schema.String.pipe(Schema.brand("SpawnId"));
export type SpawnId = Schema.Schema.Type<typeof SpawnId>;
export const RunStatus = Schema.Literal("pending", "running", "complete", "failed", "cancelled");
export type RunStatus = Schema.Schema.Type<typeof RunStatus>;
export const SpawnOptions = Schema.Struct({
agent: Schema.NonEmptyString,
systemPrompt: Schema.NonEmptyString,
prompt: Schema.NonEmptyString,
model: Schema.NonEmptyString,
});
export type SpawnOptions = Schema.Schema.Type<typeof SpawnOptions>;
export const SpawnResult = Schema.Struct({
text: Schema.String,
sessionRef: Schema.NonEmptyString,
agent: Schema.NonEmptyString,
model: Schema.NonEmptyString,
driver: Schema.NonEmptyString,
exitCode: Schema.Number,
stopReason: Schema.optional(Schema.String),
errorMessage: Schema.optional(Schema.String),
});
export type SpawnResult = Schema.Schema.Type<typeof SpawnResult>;
8.2 Error model#
All errors are tagged Effect data errors.
class ConfigError extends Data.TaggedError("ConfigError")<{ message: string }> {}
class RunNotFoundError extends Data.TaggedError("RunNotFoundError")<{ runId: string }> {}
class DriverError extends Data.TaggedError("DriverError")<{ driver: string; message: string }> {}
class ProgramExecutionError extends Data.TaggedError("ProgramExecutionError")<{ runId: string; message: string }> {}
class PersistenceError extends Data.TaggedError("PersistenceError")<{ path: string; message: string }> {}
8.3 Core services#
Service contracts may use interface, but only for capabilities (methods), not domain data modelling. Their methods remain Effect-typed.
interface RunStore {
create(meta: RunMeta): Effect.Effect<void, PersistenceError>;
appendEvent(runId: RunId, event: MillEvent): Effect.Effect<void, PersistenceError>;
setStatus(runId: RunId, status: RunStatus): Effect.Effect<void, PersistenceError>;
setResult(runId: RunId, result: RunResult): Effect.Effect<void, PersistenceError>;
getRun(runId: RunId): Effect.Effect<RunRecord, RunNotFoundError | PersistenceError>;
listRuns(filter?: RunFilter): Effect.Effect<ReadonlyArray<RunRecord>, PersistenceError>;
}
interface Driver {
readonly name: string;
readonly spawn: (
input: DriverSpawnInput,
) => Effect.Effect<DriverSpawnHandle, DriverError, Scope.Scope>;
}
interface DriverSpawnHandle {
readonly events: Stream.Stream<DriverEvent, DriverError>;
readonly raw: Stream.Stream<Uint8Array, never>;
readonly result: Effect.Effect<SpawnResult, DriverError>;
readonly cancel: Effect.Effect<void, never>;
}
interface Executor {
readonly name: string;
readonly runProgram: (
input: ProgramRunInput,
) => Effect.Effect<ProgramRunHandle, ProgramExecutionError, Scope.Scope>;
}
interface ProgramRunHandle {
readonly events: Stream.Stream<MillEvent, ProgramExecutionError>;
readonly result: Effect.Effect<RunResult, ProgramExecutionError>;
readonly cancel: Effect.Effect<void, never>;
}
8.4 Engine service#
interface MillEngine {
submit(input: SubmitRunInput): Effect.Effect<SubmitRunOutput, ConfigError | PersistenceError | ProgramExecutionError>;
runSync(input: SubmitRunInput): Effect.Effect<RunResult, ConfigError | PersistenceError | ProgramExecutionError>;
status(runId: RunId): Effect.Effect<RunRecord, RunNotFoundError | PersistenceError>;
wait(runId: RunId, timeout: Duration.DurationInput): Effect.Effect<RunRecord, RunNotFoundError | PersistenceError>;
watch(runId: RunId): Stream.Stream<MillEvent, RunNotFoundError | PersistenceError>;
cancel(runId: RunId): Effect.Effect<void, RunNotFoundError | PersistenceError>;
inspect(ref: RunOrSpawnRef): Effect.Effect<InspectResult, RunNotFoundError | PersistenceError>;
}
8.5 Effect runtime primitives used in mill#
mill implementation uses these Effect modules as first-class building blocks:
Effect.gen,Effect.scoped,Effect.acquireRelease,Effect.timeout,Effect.retry,Effect.interruptFiber/FiberSetfor supervised detached run workersQueuefor per-run ordered event bufferingPubSubfor fanout to multiple live watchersStreamfor driver output decoding and watch subscriptionsRef/SynchronizedReffor in-memory run registry snapshotsLayer+Context.Tagfor all services (RunStore,DriverRegistry,ExecutorRegistry,Clock, etc.)Runtimefor bridging program-facing Promise API (mill.spawn(): Promise<...>) to internal Effects via onlyRuntime.runPromise
Target platform services:
@effect/platform/Command@effect/platform/FileSystem@effect/platform/Path@effect/platform/Terminal@effect/platform-bunruntime layer for Bun-backed implementations
8.6 Package baseline (Effect v4 target)#
mill pins to Effect v4-compatible package line:
{
"dependencies": {
"effect": "^4.x",
"@effect/platform": "^1.x",
"@effect/platform-bun": "^1.x",
"@effect/schema": "^1.x"
}
}
(Exact minor versions are implementation-time decisions; API usage must stay within documented stable modules.)
8.7 File layout + naming (boundary is visible in filenames)#
src/
public/
mill.api.ts # Promise-based user API
discovery.api.ts # Promise-based CLI/discovery payload builders
types.ts # user-facing interfaces allowed
domain/
run.schema.ts # Schema-based domain models (no interfaces)
spawn.schema.ts
internal/
engine.effect.ts # internal Effect programs/services
run-store.effect.ts
driver.effect.ts
runtime/
worker.effect.ts
Naming rules:
*.api.ts=> user boundary (Promise + interfaces allowed)*.schema.ts=> domain data contracts (Schema+Schema.Typeexports)*.effect.ts=> internal runtime/effectful orchestration code
If a file defines domain entities and is not *.schema.ts, it is considered a spec violation.
8.8 Quick classification examples#
Allowed (public boundary):
// src/public/mill.api.ts
export interface Mill {
spawn(input: SpawnInput): Promise<SpawnOutput>;
}
Required (internal):
// src/internal/engine.effect.ts
export const submit = (
input: SubmitRunInput,
): Effect.Effect<SubmitRunOutput, SubmitError, RunStore | DriverRegistry> =>
Effect.gen(function* () {
// ...
});
Required (domain):
// src/domain/run.schema.ts
export const RunRecord = Schema.Struct({
id: RunId,
status: RunStatus,
startedAt: Schema.String,
});
export type RunRecord = Schema.Schema.Type<typeof RunRecord>;
Disallowed:
// src/domain/run.ts
export interface RunRecord { // lint error
id: string;
status: string;
}
8.9 Promise bridge and decode boundaries#
- Allowed bridge:
Runtime.runPromiseonly
- Disallowed bridges:
Effect.runPromiseEffect.runPromiseExitRuntime.runPromiseExit
- Bridge location:
- boundary adapters only (
src/public/**, CLI boundary entrypoints)
- boundary adapters only (
Decode policy:
JSON.parseis only allowed in codec/schema decoding modules (*.codec.ts,*.schema.ts).- Parsed values must be validated with
Schema.decodeUnknown*before use. - Ad-hoc parsing in engine/runtime/business modules is disallowed.
9) Event model#
Two tiers:
Tier 1 (structured, persisted)#
Required core events:
run:startrun:statusrun:completerun:failedrun:cancelledspawn:startspawn:milestonespawn:tool_callspawn:errorspawn:completespawn:cancelled
All tier-1 events must include:
schemaVersion(integer, starts at1)runId- event
type(discriminant) - monotonic sequence number
- timestamp
Encoding/decoding requirements:
- persisted event payloads are defined as a Schema discriminated union
- writers encode from typed values
- readers decode with
Schema.decodeUnknown* - unknown schema versions are surfaced as typed decode errors
Tier 1 is written to events.ndjson and is the source for watch (events channel), status/wait terminal checks, and extensions.
Tier 1 lifecycle invariants#
Exactly one terminal event is allowed per run and per spawn:
- run terminal set:
run:complete | run:failed | run:cancelled - spawn terminal set:
spawn:complete | spawn:error | spawn:cancelled
Transition table:
run: pending -> running -> complete|failed|cancelled
spawn: pending -> running -> complete|error|cancelled
Terminal states have no outgoing transitions.
mill wait resolves on first observed terminal event and treats additional terminal events as invariant violations.
Tier 2 (io passthrough, ephemeral)#
- line-oriented IO from driver/program streams
- available live via
watch --channel io(or merged viawatch --channel all) - not persisted by engine
10) Driver architecture#
10.1 Generic driver + codec split#
Core does not encode vendor semantics.
processDriver(...)andhttpDriver(...)are generic factories.codecparses native output intoDriverEvent+SpawnResult.
interface DriverCodec {
readonly decodeEvent: (chunk: Uint8Array) => Effect.Effect<ReadonlyArray<DriverEvent>, CodecError>;
readonly decodeFinal: (aggregate: ReadonlyArray<Uint8Array>) => Effect.Effect<SpawnResult, CodecError>;
readonly modelCatalog: Effect.Effect<ReadonlyArray<string>, never>;
}
10.2 Process driver execution (Bun-backed via Effect)#
Driver process spawning MUST be implemented with Effect platform command APIs and Bun context layer.
Implementation pattern:
- Build command (
Command.make(command, ...args)) - Apply env/cwd/stdin
- Start process via platform
Commandexecutor - Consume stdout/stderr as
Stream - Parse via codec to structured events
- Await exit code and final decode
Command safety requirements:
- commands must be built as arg vectors (
Command.make(cmd, ...args)) sh -lc,bash -lc, and interpolated shell command strings are disallowed- untrusted/user-provided values must flow as args, never shell source text
The implementation layer includes @effect/platform-bun runtime context so process operations are backed by Bun spawn internally while preserving typed Effect semantics.
11) Executor architecture#
11.1 Direct executor (default)#
- executes the TS program using Bun in local environment
- injects
globalThis.mill - enforces scoped lifecycle and cancellation
11.2 VM executor (optional)#
- same engine contracts
- runs program in sandboxed runtime (docker/firecracker/gvisor)
Executor has no driver knowledge.
12) Program API injected into runtime#
This is a user-facing boundary, so Promise-returning signatures are intentional.
declare global {
const mill: {
spawn(opts: SpawnOptions): Promise<SpawnResult>;
// extension APIs merged in at runtime
[key: string]: unknown;
};
}
Runtime validation:
systemPromptmust be non-emptypromptmust be non-emptyagentmust be non-empty
Behavior:
- each
spawnallocatesspawnId - engine emits
spawn:start - driver handle streams events
- engine maps to tier-1 events and persists
- resolve final
SpawnResult
13) Background worker process#
Internal worker command (private API):
mill _worker --run-id <id> --program <abs-path> --config <resolved-config> [--driver ...] [--executor ...]
Worker responsibilities:
- mark run
running - execute program through engine
- append tier-1 events
- write final
result.json - mark terminal status exactly once (idempotent finalize)
CLI run command only submits and detaches worker (unless --sync).
14) Extensions#
interface Extension {
readonly name: string;
readonly setup?: (ctx: ExtensionContext) => Effect.Effect<void, ExtensionError, Scope.Scope>;
readonly onEvent?: (event: MillEvent, ctx: ExtensionContext) => Effect.Effect<void, ExtensionError>;
readonly api?: Record<string, (...args: ReadonlyArray<unknown>) => Promise<unknown>>;
}
Rules:
- Extension failure does not crash engine by default; failure becomes
extension:errorevent. apicontributions are namespaced into injectedmillobject.- Extension hooks (
setup,onEvent) stay Effect-native. - Extension
apiis user-facing, therefore Promise-based by contract. - Promise adapters for extension API must use
Runtime.runPromiseas the only bridge.
15) Observers#
Observers consume tier-1 stream (and optionally tier-2 live io stream):
mill watch --channel eventsmill watch --channel io|all- future TUI/web UI
- automation reading NDJSON
Observers are read-only; they do not mutate engine state.
16) Session ownership + pointers#
Spawn sessionRef values are emitted in spawn:complete events and summarized in result.json.
Engine never normalizes full transcript ownership.
17) Cancellation semantics#
mill cancel <runId>:
- mark run as cancelling
- interrupt worker fiber
- propagate cancel to all live spawn handles (
handle.cancel) - append
run:cancelled(only if run is not already terminal) - mark terminal state
cancelled
Cancellation must be interruption-safe and idempotent. If run is already terminal, cancellation is a no-op.
18) SDK contract (@mill/core)#
interface CreateEngineInput {
readonly config: MillConfig;
}
declare const createEngine: (
input: CreateEngineInput,
) => Effect.Effect<MillEngine, ConfigError, Scope.Scope>;
CLI is a thin wrapper around SDK service methods.
18.1 Package export boundary#
package.json exports must expose only public entrypoints (src/public/** build outputs).
- consumers must not import
src/internal/**/src/runtime/**directly - internal modules are considered private implementation detail
- CI should fail if an internal path is exported
19) Constraint toolchain (cedar-style)#
This is mandatory for mill repo setup.
19.1 Tooling#
ast-grep(structural guardrails)oxlint(fast lint)oxfmt(format)tsgo(@typescript/native-preview) for typecheckbun testfor tests
19.2 Required files#
.ast-grep/
sgconfig.yml
rules/
no-any.yml
no-as-unknown-as.yml
no-bun-globals.yml
no-date-now-outside-clock.yml
no-dot-then.yml
no-dynamic-import.yml
no-effect-runpromise.yml
no-interface-for-domain-models.yml
no-interface-outside-public.yml
no-json-parse-outside-codec.yml
no-math-random-outside-random.yml
no-node-imports.yml
no-process-env-outside-config.yml
no-promise-outside-public.yml
no-public-import-internal.yml
no-raw-promise.yml
no-runtime-runpromise-outside-boundary.yml
no-shell-string-command.yml
no-stub-functions.yml
no-throw.yml
no-try-catch.yml
tests/
*.test.yml
.oxlintrc.json
.oxfmtrc.json
scripts/
check-exports.ts
sgconfig.yml pattern (cedar-style):
ruleDirs:
- .ast-grep/rules
19.3 Required scripts#
{
"scripts": {
"test": "bun test",
"typecheck": "tsgo --noEmit",
"lint": "oxlint .",
"lint:fix": "oxlint . --fix",
"lint:ast-grep:test": "ast-grep test --config .ast-grep/sgconfig.yml --skip-snapshot-tests",
"lint:ast-grep": "ast-grep scan --config .ast-grep/sgconfig.yml src --error",
"lint:effect": "ast-grep scan --config .ast-grep/sgconfig.yml src/internal src/domain src/runtime --error --filter 'no-(raw-promise|try-catch|throw|dot-then|any|bun-globals|node-imports|dynamic-import)'",
"lint:boundary": "ast-grep scan --config .ast-grep/sgconfig.yml src --error --filter 'no-(interface-outside-public|promise-outside-public|interface-for-domain-models|effect-runpromise|runtime-runpromise-outside-boundary|public-import-internal)'",
"lint:runtime-safety": "ast-grep scan --config .ast-grep/sgconfig.yml src/internal src/domain src/runtime --error --filter 'no-(json-parse-outside-codec|shell-string-command|process-env-outside-config|date-now-outside-clock|math-random-outside-random)'",
"lint:exports": "bun run scripts/check-exports.ts",
"format": "oxfmt . --write",
"format:check": "oxfmt . --check",
"check": "bun run lint:ast-grep:test && bun run lint:effect && bun run lint:boundary && bun run lint:runtime-safety && bun run lint:exports && bun run lint:ast-grep && bun run lint && bun run format:check && bun run typecheck && bun test"
}
}
19.4 Baseline lint/format config#
.oxlintrc.json:
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"env": { "builtin": true },
"categories": { "correctness": "error" },
"ignorePatterns": ["node_modules", ".jj", "dist"]
}
.oxfmtrc.json:
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": ["node_modules", ".jj", "dist", "SPEC.md"]
}
19.5 Guardrail intent#
- ban direct Bun globals (
Bun.spawn,Bun.file, etc.) in core runtime - ban direct
node:imports in app modules - ban untyped throw/catch/promise patterns
- enforce Effect-centric architecture and composability
- enforce boundary policy:
no-interface-for-domain-models: domain entities must come fromSchemano-interface-outside-public: interfaces are allowed only insrc/public/**and*.d.ts(plus explicit allowlist files like config declarations)no-promise-outside-public: Promise-returning contracts are allowed only at user boundary files (*.api.ts,src/public/**)no-effect-runpromise: banEffect.runPromise*usage entirelyno-runtime-runpromise-outside-boundary: onlyRuntime.runPromisemay bridge, and only in boundary adaptersno-public-import-internal: public API modules cannot import private internals directly
- enforce parsing/process/runtime safety:
no-json-parse-outside-codec: restrictJSON.parseto decode modules and require Schema decodeno-shell-string-command: disallow shell-eval process invocation patternsno-process-env-outside-config: restrict env reads to config/bootstrapno-date-now-outside-clock: force injected clock usageno-math-random-outside-random: force injected random service usage
Practical exception policy:
- internal service capability interfaces (method-only, Effect return types) are allowed in
*.service.ts/*.effect.tsthrough explicit ast-grep rule allow patterns - any interface with data fields in
src/domain/**,src/internal/**,src/runtime/**is a lint error - codec/schema files are allowlisted for parsing operations; all downstream modules consume decoded typed values
19.6 Required contract tests#
--jsonmode contract tests:- stdout contains valid JSON/JSONL only
- human-readable diagnostics are emitted to stderr only
- lifecycle contract tests:
- exactly one terminal event per run
- exactly one terminal event per spawn
- no terminal -> non-terminal transitions
- duplicate terminal emissions are ignored or rejected deterministically
20) Invariants#
- Every run has append-only tier-1 event log.
- Every spawn completion includes
sessionRef. - Engine persists orchestration state only (not full transcript).
- Public user APIs are Promise-based façades; internal APIs remain Effect-typed.
--jsonmode writes machine payloads tostdoutonly; human diagnostics go tostderr.- Each run/spawn emits exactly one terminal event and never transitions afterward.
- All persisted tier-1 events include
schemaVersionand decode via Schema unions. Runtime.runPromiseis the only permitted Effect→Promise bridge.- Runtime/domain internals do not read
process.env,Date.now(), orMath.random()directly. mill runreturns immediately by default.
21) v0 non-goals#
- hosted control plane / multi-tenant server
- built-in template subcommands
- advanced workflow DSLs beyond plain TS
- driver hot-swapping policies inside program logic
22) Implementation order#
- Core domain schemas + error model
- RunStore + event append persistence
- Generic process driver + one codec (pi or claude)
- Engine submit/status/wait/watch/cancel
- Worker process + detached
run watchchannel finalization + cancellation bridge- Extension hooks
- Guardrail toolchain + rules/tests
23) Canonical program example#
const scan = await mill.spawn({
agent: "scout",
systemPrompt: "You are a code risk analyst. Prioritize highest-impact findings.",
prompt: "Review src/auth and summarize top security and reliability risks.",
model: "openai/gpt-5.3-codex",
});
const synth = await mill.spawn({
agent: "synth",
systemPrompt: "You turn findings into an execution-ready plan.",
prompt: `Create a step-by-step remediation plan from this analysis:\n\n${scan.text}`,
});
console.log(synth.text);
This remains plain TypeScript orchestration with await / Promise.all and no DSL.