Workflow filesystem API¶
For implementers of this protocol, and for anyone who needs to know exactly what a workspace on disk means.
Status and scope¶
This document specifies the filesystem protocol around which the
httk-workflow engine is built. It is the normative on-disk protocol rather
than Python API documentation. The current httk.workflow implementation
writes and serves the core-v2 profile and supports the
transactional-data-v1 and detached-transfer-v1 extensions; relocation and
cross-workspace children remain reserved optional extensions and are rejected
rather than partially executed.
core-v2 is core-v1 plus four on-disk refinements: every spawn.json entry
carries a mandatory unique label; a join summary records typed per-child
observations which the next activation also reads as children in its attempt
context; runner in job.json may name a shared runner outside the payload
through source and sha256; and a runner-declared failure marked
retryable is retried within the job’s existing attempt budgets. A core-v1
workspace remains readable, so it can be inspected and exported, but is never
mutated in place: the sections below describe core-v2.
The protocol is language independent. A workflow step may be a shell script, a Python program, a compiled executable, or any other program that can read and write files and atomically rename a file or directory.
The design descends from the ht.task.* directories, ht_steps,
ht.nextstep, subtasks, and ht.atomic.* replay mechanism in httk v1. It keeps
the central property of that design: neither a workflow step nor a task manager
is ever required to run cleanup code. Either may disappear between any two
instructions. A later task manager must be able to identify the last commit
point and continue from it.
The design target is workspaces larger than the measured local snapshot in Scale benchmarks; that target is not a capacity measurement. Metadata inode count, directory fan-out, scheduler scan cost, and manual filesystem inspection are therefore correctness-level design concerns, not later optimizations.
The protocol covers:
submission and execution of dynamic, multi-step jobs;
safe competition between any number of task managers;
automatic and manual continuation;
dynamic fan-out into child jobs and later joins;
optional transactional contributions to durable job data;
explicit, unexpected, and dependency failures;
durable history and discovery of failed jobs.
It does not promise transactional semantics for effects outside the workflow filesystem. Sending mail, submitting to a second queue, or changing a remote database must be made idempotent in that external system, for example by using the httk job and activation IDs as idempotency keys.
Normative language¶
The words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are used in their usual specification sense.
Protocol JSON is UTF-8. Unknown object members MUST be ignored when reading a compatible major format version.
Design summary¶
The core representation is deliberately small:
A job has one payload directory containing one required metadata file,
job.json. Its parent path is an arbitrary, user-chosen placement path.A job has exactly one small marker file in the global
state/tree.The marker’s location is the sole authority for the job’s current state. It is atomically renamed between
submitted,ready,claimed,running,committing,waiting, and terminal state directories.Transition details and history are packed into shared append-only journal segments. There is no state-record file, event directory, failure file, or revision directory per job.
An active attempt temporarily adds one small control directory. Application execution may use either one persistent
run/workdir or an isolatedrun.<attempt-id>/.data/and replayable transactions are optional. Jobs that opt in receive all-or-none publication at attempt boundaries; jobs such as large VASP runs may instead keep all mutable state directly in persistentrun/.
The expected steady-state metadata cost for a job with no retained application data is:
Object |
Per job |
Lifetime |
|---|---|---|
Job payload directory |
1 directory |
Job lifetime |
|
1 file |
Job lifetime |
Authoritative state marker |
1 file |
Job lifetime |
Shared journal records |
0 files |
Packed into writer segments |
Runner |
0 files |
Shared and referenced by digest, unless it lives in the payload |
|
0 or 1 directory |
Job lifetime, when a runner keeps state across attempts |
Attempt control directory |
0 or 1 directory |
Attempt/retention lifetime |
Persistent/isolated workdir |
0 or 1 directory |
Application policy |
Per-state/per-event/per-failure files |
0 |
Not used |
Design arithmetic, not a measurement: the permanent floor is therefore
three inodes: the payload directory, its
job.json, and the one marker. The floor is reachable in practice and not only
in theory, because a runner may live outside the payload — runner.source
naming the workspace runner store or an installed search path, pinned by
runner.sha256 — so a partitioned campaign whose children all execute the same
program stores that program once and each child’s payload is exactly its
job.json. A payload runner is the alternative, not the requirement, and costs
whatever its own files cost. A runner that keeps state across the attempts and
steps of one job adds .httk-job/, and a live or retained attempt adds its
attempt-control directory and workdir.
Shard and journal directories are shared by many jobs. Application inputs, outputs, code, and logs naturally add their own files; the table only counts workflow metadata. Arbitrary placements can nevertheless make some placement directories unique to one job, and old state kinds can temporarily retain empty copies of those paths. That directory overhead is operationally relevant even though it is not a permanent per-job protocol object.
Concepts¶
A workflow workspace is one self-contained filesystem tree with an immutable workspace UUID, its jobs, authoritative state markers, and journals.
A watch root is an ordinary directory below which a manager discovers workflow workspaces. A manager may also receive explicit workspace paths and may supervise several workspaces at once.
A job is the durable unit of scheduling, history, and final success or failure.
A job key is the filesystem component
[<tag>--]<job-uuid>. The UUID is authoritative; the optional tag is for human
navigation.
A placement is an arbitrary relative parent path below a workspace, for example
project-17/0/03a. A payload at that placement has the path
<placement>/<job-key>.
A step is an application-defined name such as relax or collect. The set
of possible step names need not be declared in advance.
An activation is one logical request to execute a step. Advancing from one step to another creates a new activation. Deliberately advancing to the same textual step name also creates a new activation.
An attempt is one physical execution of an activation. Retrying after a timeout or abandoned allocation creates a new attempt of the same activation.
A data generation identifies the committed contents of an optional job
data/ tree. It is absent for jobs that do not use transactional data and
advances after a successful transaction for jobs that do.
An outcome is the step’s atomically published request to advance, wait, succeed, fail, retry, or pause.
A child job is an ordinary job whose immutable definition names a parent. Children may themselves create children.
Required filesystem semantics¶
All correctness-critical paths within one workflow workspace MUST reside on one filesystem on which:
renaming a file within that filesystem is atomic;
renaming a directory within that filesystem is atomic;
a successful rename removes the source and installs the destination as one indivisible namespace operation;
a failed rename is reported to the caller.
Atomic rename of the exact current marker is the compare-and-swap operation.
The protocol never depends on flock, advisory locks, PID uniqueness, or an
exit trap.
The baseline guarantee is process-interruption safety. Storage-crash
durability additionally requires an implementation to synchronize new file
contents and affected parent directories before publishing their names.
Implementations claiming storage-crash durability MUST use fsync or an
equivalent operation in the order required by the filesystem.
The following require an explicit backend adapter and validation:
source and destination paths on different mounts;
object stores that only emulate rename;
synchronization tools operating on a live root;
network filesystems without coherent atomic rename;
filesystems on which a client can indefinitely cache a removed name.
Modification times and wall clocks are evidence for lease expiry, but never provide fencing. Correctness comes from moving the one current state marker.
Conformance profiles¶
The protocol is deliberately phased. A conforming core implementation of the current profile, core-v2, supports:
one workflow workspace per job and all references within that workspace;
submission, validation, claiming, leases, execution, and outcomes;
persistent and isolated workdirs;
retries, joins, failures, cancellation, and operator requests;
packed journals, verified marker renames, startup recovery, and garbage collection.
The following are optional extensions:
Extension |
Additional behavior |
|---|---|
|
Replayable contributions to |
|
Moving payloads between placements in one workspace. |
|
Cross-workspace children, joins, and coordinated transfer. |
|
Sealed standalone transfer bundles. |
A workspace declares its core profile and enabled extensions in format.json.
A manager MUST refuse to mutate a workspace whose core profile or enabled
extensions it does not support. It MAY attach such a workspace read-only for
inspection. Unknown state kinds are never treated as failed or orphaned jobs.
Withdrawn: priority-bands-v1. An earlier revision defined an extension
that sharded state/ready/ into ten p0xx/ through p9xx/ directories. It is
withdrawn and MUST NOT be enabled. It bought nothing: the exact priority is in
every marker basename already, so nothing needed the directory to know a job’s
priority; a scan looking for the best available work still had to walk all ten
bands; and because a band is part of the marker path, enabling it was an
irreversible decision taken at workspace creation. It also made priority a
placement-changing transition, which is the one thing ordinary transitions
promise not to do. The p<priority> field of the marker basename stays exactly
as specified — nothing about priority encoding changes. A workspace that
declares this extension cannot be interpreted, because its ready markers carry
one path component that would otherwise read as a placement, so an
implementation MUST refuse to attach it — read-only included — and say that the
workspace has to be re-initialized rather than migrated.
This split is about implementation scope, not separate data models. Extensions retain the same job definition, marker, journal, and verified-rename rules.
Workspace layout and arbitrary placement¶
A workspace is an ordinary directory. Its protocol control data are below
WORKSPACE/.httk-workflow/; job payload directories may be placed at any valid
relative path outside that reserved directory:
WORKSPACE/
├── .httk-workflow/
│ ├── format.json
│ ├── tmp/
│ ├── quarantine/
│ ├── state/
│ │ ├── submitted/<placement>/
│ │ ├── ready/<placement>/
│ │ ├── claimed/<placement>/
│ │ ├── running/<placement>/
│ │ ├── committing/<placement>/
│ │ ├── cancelling/<placement>/
│ │ ├── relocating/<placement>/
│ │ ├── transferring/<placement>/
│ │ ├── waiting/<placement>/
│ │ ├── paused/<placement>/
│ │ ├── succeeded/<placement>/
│ │ ├── failed/<placement>/
│ │ └── cancelled/<placement>/
│ ├── journal/
│ │ └── <writer-id>/<segment-number>.hwj
│ ├── managers/
│ │ └── <manager-id>/
│ │ ├── manager.json
│ │ └── heartbeat.json
│ └── requests/
│ ├── tmp/
│ ├── ready/
│ ├── claimed/
│ └── retired/
└── project-17/
└── 0/
└── 03a/
└── silicon-relax--01234567-89ab-cdef-0123-456789abcdef/
├── job.json
├── data/
├── files/
├── run/
└── .httk-attempt.<attempt-id>/
Here the job placement is project-17/0/03a. Its authoritative marker has a
parallel path such as:
.httk-workflow/state/ready/project-17/0/03a/
└── silicon-relax--01234567-89ab-cdef-0123-456789abcdef.p500.g4.<record-ref>
The protocol assigns no meaning to the placement components. They may represent projects, user names, dates, hash shards of any depth, or a mixture. Different jobs in one workspace may use completely different placement schemes.
The layout shows all defined extensions. A core-only workspace need not create
relocating/ or transferring/, and no empty state-kind or placement
directories are required.
files/, data/, a workdir, and attempt control are created only when
required. Empty placeholder directories SHOULD NOT be created. Empty placement
directories have no protocol meaning and may be pruned, subject to the
rename/prune rules below.
format.json identifies the self-contained workspace:
{
"format": "httk-workflow-filesystem",
"format_version": 1,
"core_profile": "core-v2",
"extensions": [],
"record_ref_encoding": "hwref-v1",
"workspace_id": "b588833b-87ea-4da2-b860-1c9e768cfbc1",
"created_at": "2026-07-24T12:00:00Z",
"policy": {
"visibility_deadline_seconds": 5.0,
"lease_seconds": 900.0,
"journal_segment_bytes": 67108864,
"retention": {}
}
}
Workspace policy¶
Everything this specification calls configured is one object in
format.json, because a tunable that lives in each process cannot be agreed
on by two implementations attaching the same workspace. The policy object is
part of format version 1 and holds exactly these members:
Key |
Type |
Default |
Meaning |
|---|---|---|---|
|
number |
|
How long a reader keeps re-probing before it declares a marker rename or a referenced journal frame incoherent. |
|
number |
|
The default claim lease of a manager that does not state its own. |
|
integer |
|
The size at which a writer rotates to its next journal segment. |
|
object |
|
Optional |
An implementation MUST refuse a policy member it does not know rather than
ignore it, and MUST read an absent policy object, or an absent member of one,
as the default above; a workspace written before this section existed is
therefore attached without migration. Values are validated on write:
lease_seconds is at least one second, visibility_deadline_seconds is at
most one day, and journal_segment_bytes is at least 4096.
Policy is administrative rather than protocol state. A change is an ordinary
read-modify-write of format.json through an exclusively created temporary
file and a rename, so no reader ever sees a torn object, but concurrent policy
writers are not serialized against each other and the last writer wins. A
manager reads policy when it attaches; a change reaches already-running
managers only when they restart.
There is no configured sharding depth and no priority directory level at all:
priority is encoded in marker names, and the withdrawn priority-bands-v1
extension described in “Conformance profiles” is the only thing that ever
proposed otherwise. A marker’s path below its state kind is its placement and
nothing else.
Managers therefore provide best-effort priority scheduling: a cold start and an incremental scan may temporarily discover lower-priority work first. Strict global priority is not a guarantee of this protocol.
.httk-workflow/tmp/ contains unpublished entries. Task managers MUST ignore
it for scheduling. Garbage collection may remove old temporary entries, but
correctness MUST NOT depend on cleanup.
Placement components MUST be normalized relative path components. Empty
components, ., .., NUL bytes, and .httk-workflow are forbidden. Each
component must fit the underlying filesystem’s filename limit. A workspace MAY set
policy limits on depth and total relative path length, but these are operational
limits rather than a protocol sharding scheme.
Managing and combining workspaces¶
A core manager may attach several independent workspaces for operational
convenience, but jobs and joins remain within their own workspaces. Cross-workspace
references and coordinated movement require multiworkspace-v1.
A task manager accepts any combination of:
explicit workspace paths;
watch roots below which it discovers directories containing
.httk-workflow/format.json.
The manager identifies a workspace by workspace_id, not its current absolute path.
The same underlying workspace discovered through two path aliases is attached
once. Journal references are workspace-relative and include the workspace ID when used
from another workspace.
If two discovered roots declare the same workspace_id, a manager MUST prove that
they identify the same underlying directory before treating them as aliases.
Suitable evidence is an equal filesystem object identity obtained from open
directory handles, device/inode identity where reliable, or an equivalent
backend facility. Equal format.json bytes, UUIDs, or path canonicalization
alone are insufficient because a copied backup has all three. If equivalence
cannot be proved, the manager MUST refuse both roots for mutation and report a
loud duplicate-workspace-ID error; it MUST NOT attach an arbitrary winner.
This permits both common arrangements:
# One workspace with projects as placement prefixes
WORKSPACE/project-a/00/17/<job-key>
WORKSPACE/project-b/hash/x9/<job-key>
# A watch root containing self-contained project workspaces
WATCH/project-a/.httk-workflow/format.json
WATCH/project-a/00/17/<job-key>
WATCH/project-b/.httk-workflow/format.json
WATCH/project-b/hash/x9/<job-key>
A manager may schedule from all attached workspaces in one resource pool. Job and
child references are (workspace_id, job_id) pairs; the job UUID alone is accepted
only when unambiguous among attached workspaces.
Discovery stops at a workspace boundary. Attached workspaces MUST NOT overlap unless an explicit advanced profile defines ownership of every placement prefix; the default manager rejects nested/overlapping workspace roots.
Dynamic attachment¶
A complete workspace can be built elsewhere on the same filesystem and atomically
renamed below a watch root. Its format.json, state tree, journals, and
payloads arrive together. The manager discovers the immutable workspace ID and
begins scheduling it without restarting.
Filesystem notifications are hints. Managers periodically rescan watch roots so a lost notification cannot hide an attached workspace.
Renaming an attached workspace within watched paths does not create a new workspace. Managers SHOULD hold an open directory handle and update the path associated with the same workspace ID. Copying a live workspace is forbidden: it duplicates authoritative markers and journal identity. Discovery of such a copy is handled by the duplicate-ID refusal above, including when an operator restores a backup beside the live workspace.
Dynamic detachment¶
Detaching one workspace does not stop a manager from serving its other workspaces. A detach coordinator obtains acknowledgement from every manager with a live heartbeat in that workspace. Each manager:
stops new claims from that workspace;
completes or releases manager-owned claimed jobs;
completes committing replays;
waits for, pauses, or explicitly hands off running attempts;
closes the workspace journal writer;
acknowledges that the workspace is quiescent for it.
Once all live managers acknowledge and no marker is claimed, running, or committing, the workspace directory may be atomically moved out of the watch root. A manager MUST NOT interpret disappearance of a workspace as failure of every job in it. It marks that workspace unavailable until the same workspace ID is reattached.
A raw move of a workspace containing active attempts is supported only when the same supervising managers follow the atomic rename by workspace ID and retain valid directory handles. The portable and recommended operation is controlled detach, move, then attach.
Packed transition journal¶
Creating one JSON file for every state, event, failure, and output revision would be prohibitive at this scale. Transition metadata are instead frames in shared append-only journal segments:
journal/<writer-id>/<segment-number>.hwj
writer-id is a fresh random UUID generated for every task-manager process
incarnation. The manager-id used in claims and heartbeats is likewise a
fresh process-incarnation UUID; any stable administrative name is a separate
manager_label. The writer and manager UUIDs MAY be equal but neither may be
reused by a restarted process. On startup a process MUST create a new writer
directory and its first segment with exclusive creation. It MUST NOT reopen any
existing segment for append, even if it believes that segment belonged to a
previous incarnation of the same manager. A zombie and its replacement
therefore write different paths and heartbeats.
Each process is the sole writer of its own segments and never appends to
another writer’s segment. Segments rotate at the size
policy.journal_segment_bytes configures, not per job.
Submission needs no journal file per invocation because the initial marker
uses init.
A core journal segment begins with the 12 bytes
48 54 54 4b 2d 48 57 4a 2d 56 31 0a (HTTK-HWJ-V1 plus newline).
Segment filenames are lowercase base-36 numbers without leading zeroes. Each
frame then contains:
an eight-byte unsigned big-endian payload length;
exactly that many bytes of one UTF-8 JSON record;
the 32-byte SHA-256 checksum over the eight length bytes and payload;
the same eight-byte big-endian length as a trailer.
The marker’s compact record-ref identifies writer, segment, byte offset,
length, and checksum. Format version 1 mandates the canonical filename-safe
hwref-v1 encoding:
w<writer-uuid-hex>-s<segment-base36>-o<offset-base36>-l<length-base36>-h<checksum128-hex>
The writer UUID is 32 lowercase hexadecimal digits without hyphens. Segment
numbers are unsigned 32-bit integers; byte offsets and frame lengths are
unsigned 64-bit integers. Numeric fields use lowercase base 36 without leading
zeroes except for zero itself.
checksum128 is the first 128 bits of the SHA-256 checksum over the encoded
frame length and payload, rendered as 32 lowercase hexadecimal digits. The
complete checksum remains in the frame. No alternate encoding is permitted in
a core workspace, so independent implementations resolve marker names
identically.
The worst-case noninitial marker basename is 213 ASCII bytes:
86 job-key + 5 ".p999" + 15 (".g" + 13 generation digits)
+ 1 "." + 106 maximum hwref-v1 = 213
The 86-byte job key is a 48-byte tag, --, and a 36-byte UUID. The reference
budget is 33 for w and the writer UUID, 9 for -s and a seven-digit
base-36 segment number, 15 each for -o/offset and -l/length, and 34 for
-h plus the checksum: 33 + 9 + 15 + 15 + 34 = 106. A conforming workspace MUST
support at least 213 bytes per filename component and MUST validate this at
initialization. These field limits and the tag limit MUST NOT be enlarged
within format version 1.
Before a state-marker rename, the writer MUST flush the entire frame and, in the storage-durable profile, synchronize the segment. A marker can therefore never legally reference a torn tail. An unreferenced partial final frame is ignored and may be truncated during journal repair.
This implementation runs the storage-durable profile by default. Durability is not only a manager-side property of journals and markers: the runner-side artifacts that publish work are synchronized to the same standard, because a marker or journal frame that claims a committed outcome is worthless if the outcome its storage should hold was never flushed. In the durable profile, each of the following is synchronized — its file contents, then the directory entries that name it — before the rename that makes it authoritative:
Artifact |
Synchronized before |
|---|---|
Journal frame |
the state-marker rename that references it |
State marker / request / submitted payload |
it becomes visible in |
Attempt |
the runner is launched against it |
Outcome bundle ( |
the |
Committed transaction data ( |
the manager appends the destination state frame and renames the marker out of |
Registered child payloads and their submitted markers |
the parent’s marker leaves |
Job state ( |
each atomic replace returns |
Sealed replayable workdir batch ( |
the batch is published, then retired as applied |
Two runner-side artifacts keep only process-interruption safety even in the
durable profile, because per-line synchronization would dominate their cost and
neither is authoritative workflow state: the append-only run log
(.httk-runner/runlog.jsonl) and the runner’s captured stdout.log and
stderr.log. They are evidence for an operator, not markers or committed data;
losing their tail to a power cut costs a diagnostic line, never a lost outcome
or a half-applied transaction.
In the non-durable profile every one of these keeps process-interruption safety
only: a torn write or an interrupted rename is still never observed, but a node
that loses power may lose any of the above — a journal frame, a marker, a
published outcome, half of a “committed” transaction, or a registered child. The
opt-out (--no-durable) exists for throwaway and test workspaces where that
trade buys speed. Without it, a node that loses power can leave a marker naming
a frame or an outcome its storage never received, which is exactly the damage
workspace fsck below has to repair.
On a network filesystem, visibility of a marker and visibility of the newly
extended journal segment may reach different clients at different times. A
reader that sees a referenced frame as absent, short, or checksum-incomplete
MUST close and reopen or otherwise refresh the segment and retry with bounded
backoff. It declares corruption only after the workspace’s configured
visibility deadline — policy.visibility_deadline_seconds in format.json —
and it MUST NOT mutate the marker while the referenced frame is temporarily
unreadable. Damage that no amount of waiting can repair, such as a frame whose
checksum is present and wrong, is reported at once rather than waited out. The
same deadline bounds the retries of a verified state-marker rename and of any
other metadata-visibility probe.
Every state frame includes:
{
"format": "httk-workflow-state",
"format_version": 1,
"workspace_id": "b588833b-87ea-4da2-b860-1c9e768cfbc1",
"job_id": "01234567-89ab-cdef-0123-456789abcdef",
"job_key": "silicon-relax--01234567-89ab-cdef-0123-456789abcdef",
"placement": "project-17/0/03a",
"state_generation": 4,
"kind": "ready",
"previous_record_ref": "...",
"created_at": "2026-07-24T12:00:00Z",
"step": "collect",
"activation_id": "e7f86a0e-34d6-45a7-b92d-3f4b2dc98c54",
"activation_ordinal": 3,
"attempt_ordinal": 1,
"total_attempts": 4,
"data_generation": 2,
"priority": 500,
"reason": "advance"
}
data_generation is omitted or null when job.json declares
data.mode: "none".
State frames form a backwards-linked history across writer segments. Failure, join, operator, and outcome details are embedded in the applicable state frame or in another journal frame referenced by it. They do not create per-job metadata files.
Journal segments are append-only and retained according to history policy. They may be compressed only into a random-access archive format that preserves record references. A derived SQL database or in-memory map MAY accelerate queries, but it is a cache: the state tree and referenced journal frames remain authoritative.
Three rules make such a cache safe, and they are what this implementation’s job-id-to-marker index obeys:
A hit is confirmed before it is used. The cached location is a path; the marker either is there or is not, and a marker another actor moved is detected by the check rather than reported as current state.
A miss is never an answer. Absence is reported only after the ladder in Waiting and joining has been walked to its end, including one complete scan. Any other rule would let a job that another manager has just published be reported as nonexistent.
It is process-local. This implementation keeps the index in memory, per attached workspace, built lazily from one scan and updated by every rename the same process performs. There is deliberately no on-disk index file: a workspace has many concurrent writers and no protocol-level way to order their updates to a shared derived file, so a durable cache would need a synchronization and repair story that the authoritative state tree already provides for nothing. An implementation that wants a shared derived database MAY build one, under exactly the same three rules.
Job definition and submission¶
A minimal job.json is:
{
"format": "httk-workflow-job",
"format_version": 1,
"id": "01234567-89ab-cdef-0123-456789abcdef",
"tag": "silicon-relax",
"name": "Silicon relaxation",
"workflow": "example.vasp-relax",
"runner": {
"backend": "path",
"path": "files/runner",
"arguments": []
},
"workdir": {
"mode": "persistent",
"path": "run"
},
"data": {
"mode": "none"
},
"initial_step": "prepare",
"priority": 500,
"claim": {
"pool": "default",
"required_capabilities": []
},
"retry_policy": {
"maximum_attempts_per_activation": 10,
"maximum_total_attempts": 100,
"maximum_activations": 50,
"retry_on": ["lease_lost", "timeout", "process_failure"]
},
"resources": {},
"parent": null
}
job.json is the only required metadata file in the payload directory and is
immutable after submission. Small workflow-specific parameters SHOULD be stored
directly in it instead of one file per parameter.
The optional top-level inputs member is the place for them: a JSON object with
string keys and application-defined values, opaque to the protocol and covered by
the immutable job digest like every other member. An implementation MUST reject an
inputs object whose serialization exceeds 262144 bytes; bulk content belongs in
the payload or in transactional data/. A parent that synthesizes a child job
varies normally only the child’s initial_step and its inputs.
The optional top-level declarations member carries workflow declarations: a
JSON object mapping a declaration name to one declaration document. A
declaration document states what a workflow is — its inputs, its method, its
outputs — without describing a graph, and is intended to feed provenance.
The document is carried verbatim and is opaque to this protocol. An
implementation MUST validate that each member is a JSON object and MUST NOT
interpret, wrap, normalize, or version it any further: versioning and
self-description live inside the document itself, as the $id-style members of
the property-definition conventions it follows. A declaration name MUST match
[A-Za-z0-9_][A-Za-z0-9._-]{0,63}, because it is also one file basename in the
payload area below. An implementation MUST reject a declarations object whose
serialization exceeds 262144 bytes, which is the inputs allowance again and
separate from it. Being members of job.json, declarations are immutable after
submission and covered by the immutable job digest like every other member.
Nothing is inherited: a synthesized child job carries the declarations its parent
gave it and no others.
runner.backend selects an installed execution adapter and defaults to
path when omitted. The core task manager implements path; managers MUST
leave jobs using an unavailable or disallowed backend unclaimed. This permits
specialized managers to share a workspace without either one accidentally running
the other’s job profile. Backend-specific immutable fields belong in
job.json, and their submission validation is owned by that backend.
runner.source selects the root runner.path is resolved against and defaults
to payload:
|
Root of |
|---|---|
|
the job’s own payload directory |
|
|
|
one ordered runner search path configured in the manager |
runner.path MUST remain beneath its root under every source. arguments is an
argument vector, never a shell command string. A backend may treat the path as a
backend-specific program while retaining these path-containment rules.
A payload runner is already pinned by the immutable job digest, so
runner.sha256 MUST be absent for it. Every other source names one file, or one
tree, shared across jobs by design, so runner.sha256 is REQUIRED and pins it:
the digest of a file is over its bytes, and the digest of a tree is the
canonical tree digest. Sharing one runner file across a large partitioned
campaign is the purpose of the non-payload sources.
A manager MUST NOT execute a shared runner in place. It resolves the runner,
copies it below the attempt control directory as runner, verifies
runner.sha256 against that staged copy, and executes only the copy. A digest
disagreement fails the job with runner_mismatch; a runner that cannot be
resolved fails it with runner_unavailable. Both are ordinary continuable
failures, never silent substitutions. A staged tree is entered at its top-level
run file.
An installed path may also use the reserved form pkg:<module>/<resource>,
which resolves inside an installed Python package. A manager MUST restrict that
form to an explicit module allowlist, httk.workflow by default.
The immutable job digest a manager records is the SHA-256 over the stored
job.json file bytes exactly as submitted. Nothing renormalizes those bytes, so
any implementation with a hash utility reproduces the digest.
files/ is optional and contains submitted code, templates, or immutable input
objects that require separate files. Small inputs SHOULD be embedded in
job.json.
workdir.mode is persistent or isolated:
persistentreuses the declared workdir directory across step activations and attempts. The workflow program owns recovery and cleanup of partial application files.isolatedcreates a newrun.<attempt-id>/for every attempt. It may be initialized from submitted files or transactionaldata/.
The mode MUST be explicit in job.json; the protocol has no implicit default.
data.mode is none or transactional and is also explicit. With none, the
job may keep all mutable and final application data in its persistent workdir.
It never needs to create data/, publish a transaction, or increment a data
generation. With transactional, data/ and the transaction protocol below
are available when the workspace enables transactional-data-v1.
claim and its pool are required; required_capabilities may be empty.
claim.pool is the scheduling pool or queue from which the job may be claimed.
Pool and capability labels use the same conservative component syntax as tags.
A manager advertises its pools and capabilities and MUST claim a job only when
both match. This is claim eligibility, not a workflow name and not a substitute
for quantitative resources.
The literal pool name default is reserved for jobs requiring no explicit
routing. A manager started without pool configuration MUST advertise
default. Thus a trivial deployment uses the value shown above without any
out-of-band pool agreement; sites opt into other pool names deliberately.
Retry limits are independent optional safeguards:
maximum_attempts_per_activationlimits retries of one logical activation;maximum_total_attemptslimits physical executions over the whole job;maximum_activationslimits initial plus advanced activations, including advances back to the same textual step.
The counters are durable state-frame values. Before creating an activation or
attempt, the manager checks the applicable limits. A request that would exceed
one transitions to failed with budget_exhausted; it is not launched. An
omitted limit is unbounded.
Priority is 0 through 999, where 0 is highest. It affects scheduling, not correctness.
Submission protocol¶
To submit:
Create a complete job below
.httk-workflow/tmp/, includingjob.jsonand any initialfiles/ordata/.Choose any placement and atomically rename it to
<workspace>/<placement>/<job-key>/.Create a temporary zero-length marker named
<job-key>.p<priority>.g0.init.Atomically rename that marker to
.httk-workflow/state/submitted/<placement>/<job-key>.p<priority>.g0.init.
Step 4 is the submission commit point. Before it, the placed payload is an unsubmitted orphan and may be completed, retried, or eventually collected. After it, a fully populated submitted job exists.
A task manager registers the job by validating job.json, appending its first
ready state frame, and renaming the same marker from submitted to the
mirrored path below state/ready/. A crash before this rename leaves the job
visibly submitted; another manager repeats validation.
g0.init describes the marker submission creates, not every marker that may be
found in submitted. A job can be acted on before any manager registers it —
set_priority and pause both apply to submitted — and such a request is an
ordinary verified transition, so it appends a frame and renames the marker to
<job-key>.p<new-priority>.g1.<record-ref> in the same state directory.
Registration MUST therefore accept both forms of a submitted marker: generation
0 referencing init, whose priority is the one in job.json, and a later
generation referencing a real frame, whose priority is the operator’s and may
differ from job.json. The marker is authoritative for priority in both cases;
only at generation 0 does the job.json priority have to agree with it.
If the submitted marker is well formed but job.json, its immutable files, or
their relationship to the marker fails validation, the manager appends a
failed frame with class protocol_error and moves that same marker to
state/failed/<placement>/. The frame uses the UUID and job key from the
validated submitted marker when the document’s identity cannot be trusted and
records bounded validation details. The payload is retained for diagnosis.
Implementations MUST NOT leave invalid jobs indefinitely in submitted.
An entry whose marker name or placement is itself not parseable cannot enter
the normal job state machine. A workspace repair tool moves it to a shared
.httk-workflow/quarantine/ area outside state/; managers report it loudly
and never schedule it. Names in quarantine MUST preserve or record the original
relative path without permitting collisions or traversal. Quarantine is
exceptional workspace corruption, not another job state.
Retrying submission with an existing job UUID succeeds only when the existing immutable job digest is identical and its marker already exists. A different definition under the same UUID is an error.
State machine¶
The state kinds are:
Kind |
Meaning |
|---|---|
|
Complete job awaiting manager validation and registration. |
|
Eligible to be claimed when resources permit. |
|
A manager won the claim but has not launched the attempt. |
|
The current attempt may have a live process. |
|
The attempt is fenced; its outcome is being replayed. |
|
The attempt is fenced by a cancellation; its process is being stopped and its exit verified. |
|
|
|
Transfer extensions: a quiescent payload is moving or detaching. |
|
Waiting for a declared child join. |
|
Requires an operator request to continue. |
|
Successful terminal state. |
|
Failed until explicit operator continuation. |
|
Cancelled terminal state. |
“Terminal” is used in two senses in this document, and they are not the same
thing. Terminal for scheduling means that no manager will move the marker on
its own: the three kinds succeeded, failed, and cancelled. That is the
sense a join condition uses, the sense state/failed/ is a complete collection
in, and the sense in which a failure’s own frame is the job’s last automatic
one. Permanent means that nothing moves the marker again at all, and only
succeeded and cancelled are permanent: a failed job is terminal for
scheduling but explicitly revivable by an operator continue or
override_step, which is the whole point of tracking broken jobs in the state
tree. Where this document says “terminal” without qualification it means
terminal for scheduling. cancelling is neither: it is a live state in which a
fenced attempt is being stopped.
The scheduling cycle is:
the cycle
submitted ─validate─> ready ─claim─> claimed ─launch─> running ─outcome─> committing
▲ │
└──────────── advance or retry ────────────────────────┘
committing ─────> ready | waiting | succeeded | failed | paused
back to ready
claimed ─release────────────────────────────────────────────────> ready
running ─retry within budget────────────────────────────────────> ready
waiting ─join satisfied, or on_impossible advance───────────────> ready
failed | paused ─operator continue or override_step─────────────> ready
into failed
submitted ─protocol_error───────────────────────────────────────> failed
claimed ─prepare failure──────────────────────────────────────> failed
running ─lease loss, process failure, or an exhausted budget──> failed
waiting ─dependency_failure, or a join that cannot be read────> failed
cancellation
running ─operator cancel─> cancelling ─verified exit─> cancelled
▲ │
└───┘ the exit is not proven yet: stay fenced
any other nonterminal state ─operator cancel───────────────────> cancelled
operator moves that do not change the kind, or only queue the job
submitted | ready | waiting ─pause──────────────────────────────> paused
submitted | ready | waiting | paused | failed ─set_priority────> the same kind
Every transition of the core profile, exhaustively:
From |
To |
Trigger |
|---|---|---|
|
|
Validation and registration. |
|
|
|
|
|
A manager won the claim. |
|
|
The attempt was launched. |
|
|
Release: the manager may no longer launch it — a maintenance lock appeared, its budget changed — and it undoes the attempt counters it took. |
|
|
The attempt could not be prepared: |
|
|
A valid outcome was published; the rename fences the attempt. |
|
|
Retry within budget after |
|
|
Retry budget exhausted, or a failure the policy does not retry. |
|
|
Operator |
|
|
|
|
|
A |
|
|
A |
|
|
A |
|
|
A |
|
|
The join was satisfied, or became impossible and |
|
|
|
|
|
|
|
|
Operator |
|
|
The exit of the fenced attempt was verified. |
any nonterminal |
|
Operator |
|
|
Operator |
|
the same kind |
Operator |
Each row is one verified rename of the same marker; no other rename of a marker
is defined, and the two extension states relocating and transferring are
described in their own sections.
advance may name any next step, including the same textual name. It creates a
new activation. Retry retains the activation ID and increments the attempt
ordinal.
Any non-terminal state can be cancelled by an authorized operator. Cancellation
of running first fences the attempt by moving its marker to cancelling; a
late outcome from that attempt can no longer commit. cancelling is not a
terminal state and not a quiescent one: it is the interval in which the fenced
process is stopped and its exit is verified. See
Cancellation.
When relocation-v1 is enabled, quiescent states may also pass through
relocating and return to the same logical state at a different placement.
Cancellation¶
Cancelling a job that may have a live process is three ordered steps, and the order is the guarantee:
Fence first. The manager renames the exact
runningmarker tocancelling. From that instant no manager accepts an outcome from that attempt, because the state tree no longer names it as current. Nothing has been signalled yet, so this step cannot be skipped by a race.Then stop the process. The owning manager — or, if it has died, any manager that can see the process — sends
SIGTERMto the recorded process group, andSIGKILLafter a configured grace period. Thecancellingframe names the attempt, its attempt-control directory, and its previous owner, so a recovering manager has everything it needs.Only then finish, against evidence. The marker moves to
cancelledonly once the exit has actually been verified, and the verification is recorded in thecancellationmember of the terminal frame.
A manager MUST NOT publish cancelled for an attempt it has merely signalled.
Acceptable evidence is:
|
Meaning |
|---|---|
|
The manager launched the process and reaped it; the exit status is recorded. |
|
The process was recorded on this host and its process group no longer exists. |
|
No process identity was ever durably recorded, so the launch gate guarantees the runner never executed. |
|
The job was cancelled from a state that has no live attempt at all. |
A cancellation whose process cannot be proven stopped — typically one recorded
on a different host — MUST leave the marker in cancelling, journal why, and
retry. Staying in cancelling is the safe outcome: the attempt remains fenced,
so it can produce no state, and an operator sees a stalled cancellation rather
than a terminal state that falsely asserts that nothing is still writing the
workdir. A site whose batch system can confirm that an allocation has ended MAY
treat that confirmation as evidence and record it in the same member.
Because the fence is a marker rename, cancellation composes with everything
else by construction: an attempt that publishes an outcome after being fenced
finds its marker in cancelling rather than running, and the outcome is
ignored exactly like any other outcome from a fenced attempt.
Claiming, leases, and fencing¶
To claim a ready job, manager M:
Selects the exact ready marker.
Reads the state frame and stable
job.json.Generates attempt and claim IDs.
Appends and synchronizes a
claimedstate frame.Renames that exact ready marker to the claimed state path whose basename references the new frame.
Applies the verified-transition algorithm and proceeds only if its unique claimed destination is confirmed.
All competing managers rename the same source path to different unique destinations. At most one transition can remove that source; each caller uses verified-transition resolution rather than the rename return status to learn whether it won.
The claimed frame names:
manager, writer-incarnation, and attempt IDs;
step, activation, and attempt ordinal;
input data generation when transactional data are enabled;
lease duration and start time;
matched pool and capabilities;
resource allocation;
preceding record reference.
The manager creates .httk-attempt.<attempt-id>/, prepares the selected
persistent or isolated workdir, appends a running frame, and renames the
claimed marker to running immediately before launching the application. A
local executor MAY first create a process blocked on a launch gate, durably
record that process identity, commit the running marker, and only then release
the gate to execute the application. If its manager disappears before release,
the gate MUST cause that process to exit without executing the application.
This closes the otherwise ambiguous crash window between a running transition
and recording the new process identity.
Managers update managers/<manager-id>/heartbeat.json by atomic replacement.
A recoverer uses the state frame, heartbeat, batch scheduler when available,
and a configured grace period. It MUST NOT steal a job merely because one
delayed metadata read appears stale.
A heartbeat is a statement about the manager, not about its scheduling pass, so a manager MUST NOT let one pass over the state tree hold its heartbeat: it takes heartbeat opportunities between the state kinds it scans and within long scans of one kind. A manager whose workspace is too large to serve inside one lease SHOULD also bound how many markers of a kind it processes per pass and resume the rest on the next pass, in a stable order so that no marker starves. An implementation SHOULD report a pass that consumes a large fraction of its own lease, because that is the condition under which a healthy manager begins to look abandoned.
Once policy determines that an attempt is abandoned, a recoverer appends a new claimed frame and renames the exact old claimed or running marker. That rename fences the old attempt. Even if its process later wakes, no task manager accepts its outcome because the state tree names a different attempt.
A manager taking immediate ownership transitions directly to a new claimed state. A manager merely releasing work transitions back to ready. Both retain the activation ID for a retry.
Filesystem fencing cannot prevent a partitioned old process from producing external side effects or modifying a persistent workdir, and it does not make a duplicated attempt free: two allocations burning for one activation is a real cost whichever workdir mode the job uses. Lease expiry alone is therefore never sufficient evidence to relaunch an attempt, in either mode.
For a persistent workdir, the default safe policy MUST establish that the previous writer can no longer modify the workdir—for example, by scheduler-confirmed allocation expiry or cancellation and process-group termination—before launching a replacement. A site MAY enable lease-only persistent takeover as an explicit unsafe policy.
For an isolated workdir, a replacement corrupts nothing, so the requirement is weaker but not absent: a manager MUST have one of
the recorded process is provably gone on this host,
a scheduler confirmation that the allocation has ended, or
a heartbeat that has been silent for a configured multiple of the lease — the takeover grace, by default twice the lease.
A manager SHOULD terminate the old process group or cancel its batch allocation before relaunching when it can. The grace exists because an expired lease says only that a manager is slow: a manager whose scan of a very large workspace overruns one lease is alive and its attempts are running, and taking those over at the first expired lease is how one slow node becomes twice the bill.
Every takeover MUST record its evidence in the new attempt frame — which rule admitted it, and the observed heartbeat age — and every use of an explicitly unsafe policy MUST be recorded there too.
Attempt control, workdirs, and runner contract¶
Attempt control is separate from application workdir:
<workspace>/<placement>/<job-key>/
├── .httk-attempt.<attempt-id>/
│ ├── context.json
│ ├── runner # the verified staged copy of a shared runner
│ ├── outcome.tmp.<nonce>/
│ └── outcome.ready/
├── .httk-job/ # optional runner-private job state
│ └── declarations/ # optional observed workflow declarations
│ └── <declaration-name>.json
└── run/ # persistent mode
In isolated mode the application directory is
run.<attempt-id>/ instead of run/. The runner’s current working directory is
the selected application workdir, not the attempt control directory.
.httk-job/ is runner-private state that belongs to the job rather than to one
attempt: it survives retries, step advances, and isolated workdirs, and it travels
with the payload when the job is transferred. A runner MAY store what it needs
there, atomically. Both .httk-job/ and every .httk-attempt.<attempt-id>/ are
excluded from every payload digest — submission, child registration, and detached
transfer alike — so publishing an outcome and writing job state can never disturb
an immutability check of the payload.
.httk-job/declarations/<declaration-name>.json is the one reserved name inside
that area: the observed workflow declaration of that name, the runtime-refined
counterpart of what job.json declared. A campaign that discovers its outputs
only while running writes what it observed there, and the last write of a name
wins. The document is carried verbatim and is opaque to the protocol exactly as
in job.json, the basename before .json is the declaration name and MUST
therefore satisfy the same syntax, and being below .httk-job/ the file is
excluded from every payload digest. An implementation MUST report the declared
and the observed document side by side and MUST NOT merge them; a document that
cannot be read is reported as absent, with the reading tool’s damage flag set.
Separating them matters in persistent mode: a late process from an old attempt can only publish beneath its own attempt-control name. Its outcome cannot replace or impersonate the new attempt’s outcome.
Persistent workdir¶
The same declared workdir is used across normal step advances and retries. The manager does not clean, copy, snapshot, or transactionally inspect its application files.
This mode supports, for example, a VASP workflow that retains a very large
WAVECAR in run/, modifies it over several steps, and decides for itself
whether an interrupted calculation can continue. data/ need not exist.
On retry, the manager first fences and attempts to terminate the old process, then invokes the same step activation in the same directory with a new attempt context. The workflow code examines the reason and the existing files and may:
continue directly;
remove known partial outputs and restart;
repair inputs and request another retry;
declare the job failed.
A planned advance to another step also reuses the directory, but is not a
restart: the new activation has attempt ordinal 1 and is_restart: false.
Persistent mode deliberately gives up workdir isolation. If an old process
cannot be established incapable of further writes, it may still modify run/
after being fenced from publishing an outcome. The safe default is therefore
to wait, force scheduler cancellation, or pause for manual action. Only an
explicitly configured unsafe takeover policy may accept concurrent-writer risk,
as specified under fencing above.
Isolated workdir¶
Every attempt receives a new run.<attempt-id>/. The manager may populate it
from submitted files or transactional data/ by copying, reflinking, or a
filesystem snapshot. Writes through an isolated workdir MUST NOT mutate
committed data/ before a transaction.
Old isolated workdirs may be retained for diagnosis or collected later. A manual continuation can import selected files into a new isolated workdir, with the choice recorded in history.
Context and restart detection¶
The manager MUST set:
HTTK_WORKFLOW_CONTEXT=<absolute path to attempt context.json>
HTTK_WORKFLOW_CONTROL_DIR=<absolute attempt-control path>
HTTK_WORKFLOW_WORKSPACE_DIR=<absolute workspace path>
HTTK_WORKFLOW_JOB_DIR=<absolute current payload path>
HTTK_WORKFLOW_WORKDIR=<absolute selected workdir path>
HTTK_WORKFLOW_IS_RESTART=0|1
HTTK_WORKFLOW_UNCLEAN_RESTART=0|1
HTTK_WORKFLOW_DURABLE=0|1
HTTK_WORKFLOW_ATTEMPT_REASON=<reason>
HTTK_WORKFLOW_STEP=<current step>
HTTK_WORKFLOW_PYTHON=<manager Python interpreter>
HTTK_WORKFLOW_BASH_API=<absolute native workflow Bash library>
HTTK_WORKFLOW_DATA_DIR is additionally set only for transactional-data jobs.
The JSON file is the source of truth; scalar environment variables are
language-neutral conveniences.
HTTK_WORKFLOW_DURABLE, and the durable member of the attempt context, carry
the workspace’s durability mode to the runner. A runner that publishes an
outcome, a transaction, or a child bundle synchronizes it before the rename that
makes it authoritative exactly when this is 1. A runner that does not compose
outcomes on storage itself may ignore it; the packaged Python and Bash SDKs
honour it automatically, and a context written before the member existed reads
as 0.
A manager MAY export further variables that belong to the runner libraries it ships rather than to this protocol. They are SDK or application conveniences: a conforming manager that exports none of them is still conforming, and a runner that needs one MUST treat its absence as a missing dependency of that library rather than as a protocol violation. The ones this implementation exports are:
HTTK_WORKFLOW_VASP_BASH_API=<absolute native VASP Bash library>
An unclean persistent retry context is:
{
"format": "httk-workflow-attempt-context",
"format_version": 1,
"workspace_id": "b588833b-87ea-4da2-b860-1c9e768cfbc1",
"job_id": "01234567-89ab-cdef-0123-456789abcdef",
"placement": "project-17/0/03a",
"step": "relax",
"activation_id": "e7f86a0e-34d6-45a7-b92d-3f4b2dc98c54",
"attempt_id": "a6c2c973-29e1-44e2-9649-ae419e340ac4",
"attempt_ordinal": 2,
"is_restart": true,
"is_unclean_restart": true,
"attempt_reason": "lease_lost",
"previous_attempt_id": "31fa431e-e01c-49aa-8fa8-e8af23b73c52",
"activation_reason": "advance",
"workdir_mode": "persistent",
"workdir_reused": true,
"data_generation": null,
"durable": true,
"resources": {},
"join": null,
"children": []
}
attempt_ordinal > 1 and is_restart mean that the same activation is being
retried. is_unclean_restart specifically means that the preceding attempt did
not publish and complete a valid outcome. Reasons include lease_lost,
timeout, and process_failure. requested_retry and an orderly
manual_continue may have is_unclean_restart: false.
Thus a shell or Python step does not infer restart from leftover filenames. It
reads HTTK_WORKFLOW_CONTEXT (or the scalar variables), then uses the existing
files according to application policy.
Attempt-control directories are transient metadata. Persistent workdirs are application data and MUST NOT be garbage-collected merely because an attempt ended. Isolated workdirs may be collected under their retention policy. No state transition depends on cleanup.
Publishing an outcome¶
Exit codes are not the workflow protocol. A step communicates in the
attempt-control directory named by HTTK_WORKFLOW_CONTROL_DIR:
Create
outcome.tmp.<nonce>/.Write
outcome.jsonand any transaction or child bundles inside it.Close all files, and in the storage-durable profile synchronize the whole draft tree — the outcome, its transaction manifest and staged payload, and its child bundles — in one batch.
Atomically rename the directory to
outcome.ready/, and in the durable profile synchronize the attempt-control directory so the new name survives a crash.Exit.
The directory rename is the outcome publication point. Temporary outcomes are ignored. The fixed destination is nonempty, so a second publication MUST fail rather than replace the first. The draft is unreferenced until step 4, so a crash before it discards a partial outcome whole; the single batched synchronization at step 3 is why a published outcome can never name staged data the storage never received.
A minimal outcome is:
{
"format": "httk-workflow-outcome",
"format_version": 1,
"job_id": "01234567-89ab-cdef-0123-456789abcdef",
"activation_id": "e7f86a0e-34d6-45a7-b92d-3f4b2dc98c54",
"attempt_id": "a6c2c973-29e1-44e2-9649-ae419e340ac4",
"action": "advance",
"next_step": "collect",
"message": "relaxation converged"
}
expected_data_generation is required only when the outcome contains a
transaction. It MUST equal the generation supplied in the attempt context.
An outcome MAY contain priority, an integer from 0 through 999. The manager
uses it for the marker produced by the committed action; omission preserves
the current priority. This allows a workflow decision and its scheduling
preference to share one atomic marker transition.
An outcome MAY also contain runner_steps, the array of step names the publishing
runner implements. A manager copies a valid array verbatim into the state frame it
writes and carries it forward, and ignores a malformed one with a log entry: the
member is evidence for an operator or a tool drawing the reachable steps of a job,
never an input of a manager decision. A runner that declares it SHOULD do so in the
first outcome the job publishes.
Actions are:
Action |
Required information |
Effect after commit |
|---|---|---|
|
|
Create a ready activation for that step. |
|
|
Register children and wait for the join. |
|
none |
Enter successful terminal state. |
|
|
Record a declared failure and enter failed. |
|
|
Retry this activation under policy. |
|
|
Require an operator request. |
advance and retry first apply the total and per-activation budgets from
job.json. If the requested next activation or attempt would exceed a limit,
the committed effect is failed/budget_exhausted rather than another ready
activation.
After observing a valid outcome, the manager appends a committing frame and
renames running to committing before modifying durable job data or
registering children. This fences the step and makes interrupted outcome replay
explicit in the state tree.
If a process exits without an outcome:
exit status zero is a
protocol_error, because success is ambiguous;nonzero exit is
process_failure;manager timeout is
timeout;loss of manager/allocation is
lease_lost.
Retry policy decides whether these create another attempt or
retry_exhausted. A declared fail is permanent by default. A step requesting
a managed retry uses retry.
A valid current outcome is authoritative over the process exit status. An outcome from a fenced attempt is retained only for diagnosis.
Optional transactional contributions to data/¶
Visibility guarantee¶
This section applies only when job.json declares
"data": {"mode": "transactional"}. Such a step MUST NOT modify committed
data/ directly. It publishes a replayable transaction in its outcome.
A job with data.mode equal to none omits data/ and the transaction bundle.
In particular, a persistent-workdir job may freely update application files
such as WAVECAR in run/; those files are not workflow-protocol metadata.
The required guarantee is:
A later attempt starts only after either none of a transaction or all of it has been applied to
data/.
POSIX cannot atomically rename several unrelated paths. Raw observers looking
inside data/ during committing may therefore see replay in progress. The
atomic boundary is between workflow attempts: no runner is launched while the
marker is committing.
Applications that require a simultaneously atomic tree for external readers
MAY contribute one complete version directory and atomically replace a single
current name. That is an application-level use of the same protocol, not a
mandatory per-step revision directory.
Transaction bundle¶
.httk-attempt.<attempt-id>/outcome.tmp.<nonce>/
├── outcome.json
└── transaction/
├── manifest.json
├── payload/
│ ├── results/energy.json
│ └── restart/CHGCAR
└── trash/
Example:
{
"format": "httk-workflow-transaction",
"format_version": 1,
"id": "6fc3852b-f1df-4edf-a92c-7f81c3e02465",
"expected_data_generation": 3,
"operations": [
{
"id": "energy",
"op": "put-file",
"source": "payload/results/energy.json",
"path": "results/energy.json",
"sha256": "2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881"
},
{
"id": "old-scratch",
"op": "remove",
"path": "scratch/obsolete.dat",
"missing_ok": true
}
]
}
Paths are normalized relative POSIX paths. Absolute paths, empty components,
., .., NUL bytes, and paths into protocol control data are forbidden.
Operations MUST NOT overlap. Operation IDs are unique normalized protocol
components and determine all replay scratch paths.
Required operation types are:
make-dir: create an application directory and any explicitly declared missing parents;put-file: atomically install or replace one regular file;remove: atomically rename an existing path into transaction trash;put-tree: install a complete directory tree, normally at a previously absent path;replace-tree: move the old tree to transaction trash, then install the new tree.
Put operations declare a content digest. Replacement and removal operations MAY declare an expected old digest or require absence. Preconditions prevent a replayed or stale transaction from overwriting an unexpected workdir. Symlinks, devices, sockets, and FIFOs are forbidden by default.
Every removal uses the deterministic destination
transaction/trash/<operation-id>/removed. Every replace-tree moves the old
tree to transaction/trash/<operation-id>/old before installing its
deterministic payload source. These destinations MUST NOT contain preexisting
unrelated data. Replayers create the operation directory idempotently; no
random trash name or replayer identity may affect the paths.
Idempotent replay¶
While the marker is committing, a manager applies operations in manifest
order using atomic renames and the verified-transition algorithm:
source present, destination not yet new: validate and rename source to destination;
declared directory absent: create it; an already matching directory means
make-diralready happened;source absent, destination has the declared new digest: the put already happened;
removal target absent, matching trash entry present: removal already happened;
both old tree and new source present during
replace-tree: continue its defined two-rename sequence;any other combination: stop with transaction corruption rather than guess.
More than one manager may inspect an abandoned committing marker. The
deterministic source, destination, and trash paths make concurrent replay
converge: one rename wins and every other replayer verifies the same resulting
path state. A replayer MUST perform the bounded visibility retries from the
verified-transition algorithm before declaring an impossible combination.
The manifest and deterministic operation IDs supply all replay information. No per-operation progress marker files are required.
After all operations validate as applied, the manager:
appends the destination state frame with incremented data generation when the transaction changed data;
renames the exact committing marker to ready, waiting, succeeded, failed, or paused;
only then permits transaction trash and, in isolated mode, an old isolated workdir to be collected.
If interrupted after the data changes but before the final marker rename, a new
manager sees committing, reads the outcome named by the committing frame, and
idempotently completes replay. It does not rerun the step.
This retains the useful httk v1 ht.atomic.* principle while avoiding one
permanent revision and manifest hierarchy per step.
Relocating and transferring jobs¶
This section requires relocation-v1, multiworkspace-v1, or
detached-transfer-v1 as indicated. A core implementation does not create
relocating or transferring states.
Arbitrary placement is dynamic. A job may move after submission, but a raw
mv of an authoritative payload is not a state transition: the marker would
still name the old placement. Relocation is therefore a short replayable
protocol.
Relocation within one workspace¶
Only a quiescent job may relocate. ready, waiting, paused, failed,
succeeded, and cancelled are quiescent. A claimed or running job must first
be released or fenced; a committing job must finish replay.
To move from placement A to placement B, a manager:
validates that
B/<job-key>does not exist;appends a
relocatingframe containing sourceA, destinationB, the prior logical state, priority, and exact expected record;renames the exact marker from
state/<old-kind>/A/tostate/relocating/A/, fencing scheduling;atomically renames payload directory
A/<job-key>toB/<job-key>;appends a destination frame with placement
Band the prior logical state;renames the same marker from
state/relocating/A/tostate/<old-kind>/B/.
The marker remains the sole authority throughout. Recovery from relocating
uses these cases:
Source payload |
Destination payload |
Recovery |
|---|---|---|
Present |
Absent |
Perform the payload rename and finish. |
Absent |
Present with matching job ID/digest |
Payload rename happened; finish the marker transition. |
Present |
Present |
Stop: destination collision or non-atomic copy. |
Absent |
Absent |
Stop: payload loss. |
Creation and later removal of empty placement parents are not state changes.
A batch relocation MAY move a common project/shard prefix with one directory
rename. It first moves every affected job marker to relocating and journals
one batch ID and the complete member set. Only after all members are fenced does
it rename the common payload prefix. It then returns each marker to its mirrored
destination placement. Recovery uses the batch record; no per-job coordinator
files are required.
Moving new jobs into a running workspace¶
Files may be copied or generated under .httk-workflow/tmp/ while managers
continue working. The complete payload is renamed to any chosen placement and
becomes schedulable only when its submitted marker is published. A partial
copy has no marker and is invisible.
A whole project tree containing many complete, unsubmitted payloads may be renamed into a placement prefix at once. Their markers are then published from a validated batch manifest. Marker publication is intentionally one job at a time; a batch requiring an all-at-once scheduling barrier should initially publish its members paused and release them through an explicit batch request.
If the source is on another filesystem, the copy must finish and be validated inside the destination workspace before marker publication. Atomic rename is relied on only for the final same-filesystem publication.
Moving jobs between workspaces¶
An individual job can transfer atomically between two workspaces only when both control trees and the payload source/destination are on the same filesystem. A coordinator attached to both workspaces:
moves the quiescent source marker to
transferring, recording source and destination workspace IDs, placements, transfer ID, and prior logical state;renames the payload into the destination workspace;
appends the import frame to a destination-workspace journal;
renames the same marker inode from the source
transferringtree to the mirrored destination state tree.
Until step 4, the source transferring marker remains authoritative and points to both possible payload locations for recovery. The destination cannot run the job early.
Moving across filesystems is necessarily copy-and-acknowledge rather than one
atomic filesystem transaction. The source job must first be sealed in
transferring; the destination publishes a marker only after a complete copy
and transfer-token validation; the source is retired only after explicit
acknowledgement. A backend implementing this profile must document its
duplicate-suppression and failure policy.
If the job may be named by an unresolved cross-workspace join, the source workspace MUST retain a packed forwarding record keyed by source workspace ID, job ID, job key, and old placement, naming the destination workspace and placement. It is retained for at least the maximum join-history period. A transfer implementation without such lookup support MUST reject transfer of a job participating in an unresolved join.
To detach one job without an immediately attached destination workspace, the
manager moves it to transferring, writes one compact
.httk-transfer/manifest.json inside the payload, and renames the authoritative
marker into .httk-transfer/ inside that payload. The directory is then a
sealed, nonschedulable bundle that can be moved out. Import places and validates
the complete bundle first, appends an import frame, then renames the embedded
marker into the target workspace’s state tree. The extra transfer metadata exists
only while the job is detached or retained for transfer provenance.
For moving whole projects, a self-contained workspace is preferable: controlled detach and attach carry its state tree, journals, and all arbitrary placements together.
Dynamic branching and joins¶
Child publication¶
A step places complete child bundles in its outcome:
.httk-attempt.<attempt-id>/outcome.tmp.<nonce>/
├── outcome.json
└── children/
├── spawn.json
└── jobs/
├── branch-a--<child-uuid>/
│ ├── job.json
│ └── ...
└── branch-b--<child-uuid>/
├── job.json
└── ...
Each child job.json names parent workspace, parent job, parent activation, and
spawn ID, and — a clean pre-release requirement — the parent’s placement.
spawn.json chooses the target workspace and arbitrary placement for each
child. The parent placement lets the decided-join revival guard resolve the
parent by probing its exact state set rather than scanning the workspace: a
manager deciding whether a continue would revive a child a join already
consumed reads the parent at that placement alone and never falls back to a
whole-workspace scan inside a scheduling tick. The guard is advisory, so a child
written before spawns carried the parent placement makes it probe nothing rather
than rescan; a fresh child MUST carry it. Child UUIDs and tags are chosen before outcome publication. In the
core profile, the target workspace MUST be the parent’s workspace;
cross-workspace children require multiworkspace-v1.
Every spawn.json entry MUST also carry a label: a nonempty tag-syntax name
that is unique within that spawn set. A gathering step selects its inputs by
label, so a missing or ambiguous label makes the parent’s own join unusable and
the manager rejects the published outcome with protocol_error instead of
registering any child of the set.
While the parent is committing, the manager:
moves each complete child bundle to its chosen
<workspace>/<placement>/<job-key>path;creates its one
g0.initmarker at the mirrored target-workspace path belowstate/submitted;treats an identical existing child plus marker as already registered;
fails on the same UUID with different immutable content.
Children are registered before the parent leaves committing. A crash may expose only some children, but replay registers the deterministic missing set. Registration is not rolled back. Published children may start before the parent has completed its transition; that is safe.
When a target workspace is on another filesystem, the child is first copied into that workspace’s temporary area and validated there. Its placement rename and submitted-marker publication then occur within the target filesystem while the parent remains committing.
Each child is an ordinary independently schedulable job with one job file, one state marker, its own attempts, and the ability to create more children.
Waiting and joining¶
A wait outcome explicitly names its child set:
{
"action": "wait",
"next_step": "aggregate",
"join": {
"children": [
{
"workspace_id": "b588833b-87ea-4da2-b860-1c9e768cfbc1",
"job_id": "411d9e6e-c050-451d-a851-e20f2570d7c5",
"job_key": "branch-a--411d9e6e-c050-451d-a851-e20f2570d7c5",
"placement_hint": "project-17/0/041"
},
{
"workspace_id": "b588833b-87ea-4da2-b860-1c9e768cfbc1",
"job_id": "7ead0705-e1bb-4290-9ebd-fc1b24df9005",
"job_key": "branch-b--7ead0705-e1bb-4290-9ebd-fc1b24df9005",
"placement_hint": "project-17/0/042"
}
],
"condition": "all_succeeded",
"on_impossible": {
"action": "advance",
"next_step": "handle_child_failure"
}
}
}
Supported conditions are:
all_succeeded;all_terminal;any_succeeded;at_least, with a successful-child count.
The waiting journal frame contains each exact child identity, its spawn
placement as placement_hint, and the condition. The placement is explicitly a
lookup hint rather than identity. No join file or child marker is added to the
parent directory. New unrelated descendants cannot affect the join.
A manager resolves each named child through this ladder, in order:
the finite set of state kinds at the child’s
placement_hint, when the reference carries one — ordinary state transitions never change placement, so this is a bounded number of directory lookups and resolves the normal case;its in-memory job-id-to-marker index, confirmed against the filesystem before it is used;
under
relocation-v1ormultiworkspace-v1, any packed relocation or transfer forwarding record;a complete marker scan of the named workspace.
A cache is never the only recovery path, and a cache miss is never the answer.
In the core profile a clean pre-release break makes step 1 mandatory: every join
child reference MUST carry both job_key and placement, join evaluation
resolves each child by probing the finite state set at that exact placement
alone, and it never falls back to a whole-workspace scan from inside a
scheduling tick. A reference that lacks a placement is a protocol error of
whatever published it, and the manager rejects the outcome rather than rescanning
the workspace per child. The whole-workspace scan of step 4 survives for the
extension forwarding of step 3 and for interactive resolution — job show, job why, job log, and harvest resolve — where locating an arbitrary and possibly
finished job by one exhaustive scan is acceptable; it is never entered on the
scheduling hot path. The
consequence that matters at scale is that a waiting parent’s per-tick cost is
independent of how many children its join names — each child is one hint lookup
or one confirmed index hit — rather than one complete scan per child.
Failure to find the child after bounded workspace visibility retries is an
unavailable/corrupt dependency, not evidence that a join condition is
impossible. Such a join is nevertheless never allowed to wait forever: because
children are registered before their parent leaves committing, a child that
stays unresolvable past a bounded grace is corrupt evidence, and the manager
MUST fail the parent with dependency_failure and a message naming the missing
child. This implementation records the first unresolved observation in memory
and fails the parent after join_grace_seconds, one hour by default; a
restarted manager restarts that grace, which is safe because the grace exists
only to absorb transient nonvisibility.
In the core profile, a child named by an unresolved join MUST NOT relocate, so its placement hint remains valid. Extension implementations MAY relocate it only when they provide the forwarding and full-scan fallback above.
When satisfied, the manager appends a ready frame for a new activation and
renames waiting to ready. Its attempt context summarizes the exact child state
generation, terminal data generation, and failure information. Each observation
is one object carrying the child’s workspace_id, label, job_id, job_key,
placement, kind, state_generation, record_ref, its published failure
object when it ended failed or cancelled, its data_generation, and the
workspace-relative payload_path and workdir_path through which its results
are read. The
observations appear in the attempt context both as the join summary and as the
children array, which is empty for an activation that follows no join. Child
committed
data may be exposed through read-only paths named in the context. A
transactional parent imports selected child data through its own transaction.
A non-transactional workflow may instead let application code inspect or copy
child data into its persistent workdir according to its own conventions.
Join evaluation records an observation vector containing every child’s exact marker generation, state-frame reference, and state kind. The vector need not be a simultaneous filesystem snapshot; it is the complete evidence on which the parent transition was based, and each entry must have been the child’s current authoritative marker when observed. Once the parent marker transition commits, later manual continuation of a child does not change or retract that decision.
Success is currently impossible when the recorded observation vector contains enough terminal nonsuccess states to make the condition false:
all_succeeded: any child isfailedorcancelled;any_succeeded: every child is terminal and none succeeded;at_least N: succeeded children plus nonterminal children is less thanN;all_terminal: never impossible merely because a child failed.
A manually continuable failed child still counts as terminal nonsuccess in
the current vector. If success is currently impossible, on_impossible
selects an error-handling step or the parent fails with
dependency_failure. A later revival of that child does not retract the
committed on_impossible transition. Cancellation is terminal but not
successful.
on_impossible is optional and has exactly one defined form:
{"action": "advance", "next_step": "handle_child_failure"}
advance is the only legal action, and next_step MUST be a valid step name —
any application-defined name, including the step the parent just ran. The
member behaves exactly like an advance outcome: it starts a new activation of
the parent at that step, carrying the observation vector as the join summary, so
the error-handling step sees precisely what the satisfied step would have seen.
An absent on_impossible, or one whose action is anything else, means the
parent fails with dependency_failure; a manager MUST NOT invent another
action, and MUST NOT treat an unrecognized one as a reason to keep waiting.
Reviving a child a decided join consumed¶
A join decision is final, but a child of a decided join is still an ordinary
job, and continue or override_step on it would start writing its workdir and
payload again — possibly while the parent activation that consumed it is reading
exactly those files. Nothing in the protocol can order those two writers.
A manager MUST therefore refuse a continue or override_step request for a
job that a decided join has already observed, unless the request explicitly
carries "force": true. The check is a cheap, exact one and needs no index: a
child’s job.json names its parent, and the parent’s current state frame
carries the join_summary of the activation it is in, so the refusal applies
precisely while the consuming activation is the parent’s current one. A refused
request is retired with the reason, which names the parent, so an operator can
continue the parent instead. With "force": true the manager applies the
request and MUST record the hazard it accepted in the resulting state frame, as
a revival_hazard object naming the parent, its state, and the observation that
consumed this child.
An advance or succeed outcome may also publish detached children without a join.
Failures and tracking broken jobs¶
A step declares itself broken with a fail outcome:
{
"action": "fail",
"failure": {
"code": "vasp.nonconvergent",
"message": "electronic minimization did not converge",
"details": {"iterations": 61, "log": "vasp/convergence.log"},
"retryable": false
}
}
There is exactly one failure object shape, used identically by application runners, by every language binding, and by the manager itself:
Member |
Type |
Required |
Meaning |
|---|---|---|---|
|
string |
yes |
Stable machine identity, one token without whitespace, at most 128 bytes. Matched against |
|
string |
yes |
One nonempty human sentence. |
|
object |
no |
Structured evidence, such as |
|
boolean |
no, default |
Advisory operator evidence. It never overrides |
No other member is permitted. A manager MUST validate a runner-published
failure before recording it and MUST NOT store an unvalidated object. A
malformed failure is itself a protocol violation: the job enters failed with
the manager’s own protocol_error code and a message naming the defect, so a
broken runner can never make a failure invisible to a consumer.
The manager embeds the structured failure in the failed state frame and moves
the one marker to state/failed/<placement>/. No failure.json,
ht.reason, per-job event directory, or second failure index marker is
created.
Codes emitted by this manager itself are reserved. Those currently in use are:
protocol_error— invalid submission, an outcome the protocol forbids, a malformed published failure, an unusable join, or a runner that exited successfully without publishing an outcome;process_failure— a runner that could not be launched, or that exited nonzero without publishing an outcome;lease_lost— the owning manager’s heartbeat expired;retry_exhausted—maximum_attempts_per_activationreached during retry;budget_exhausted— an attempt or activation budget exceeded;dependency_failure— a join became impossible, or a named join child stayed unresolvable past the manager’s bounded grace;transaction_corruption— a published transaction could not be replayed;runner_unavailable— a runner outside the payload could not be resolved, staged, or entered at all;runner_mismatch— the staged copy of such a runner did not match therunner.sha256the job pinned.
A runner library that dispatches steps on a runner’s behalf publishes ordinary runner failures, so its codes are reserved too. Those of the runner libraries shipped with this implementation are:
no_outcome— the step handler returned without publishing an outcome;unknown_step— the job asked for a step this runner does not implement;declared_failure— a legacyht_stepstask declared itself broken, published by the httk v1 compatibility runner only.
resource_unsatisfiable, manager_error, and cancelled remain reserved for
manager use but are not currently emitted by this implementation. timeout is
likewise reserved for a manager-enforced attempt timeout, which this manager does
not yet apply; the httk v1 compatibility runner does publish it for a legacy
task that exceeded its own timeout. Application codes SHOULD be namespaced, as in
vasp.nonconvergent, to keep them distinct from the reserved set.
The failure frame contains job, step, activation, and attempt IDs; the failure
object with its code, message, and details such as exit status or signal; retry
history; manager ID; relevant retained log paths; and data generation. For a job
with data.mode equal to none, data generation is null.
Current broken jobs are exactly the markers below state/failed/. This
directory tree is authoritative and requires no reconciliation.
If a failed job is manually continued, its marker moves elsewhere, but its failed frame remains in the backwards-linked journal history. “Ever failed” queries are answered from a compact journal scan, an optional derived database, or a periodically generated report. They intentionally do not consume another per-job marker inode.
Logs are evidence, not state. Missing or truncated logs cannot prevent recovery.
Manual continuation and control requests¶
Operators MUST NOT edit state markers, journal segments, or job.json.
They write one complete request file in requests/tmp/ and atomically rename it
into requests/ready/ under the same name, so a manager never reads a partially
written request and the publication is the same verified rename as every other
one in this protocol. A request names:
job UUID, job key, and the job’s
placement;exact expected marker generation and record reference;
requested action;
operator identity and reason;
optionally
force, the operator’s explicit acceptance of a hazard the manager would otherwise refuse;any selected retained files for manual import;
for relocation or transfer, the exact destination workspace and placement plus a unique operation ID.
Actions include:
continue: retry the current activation;override_step: create a new activation at a named step;cancel;set_priority;pause;relocate, whenrelocation-v1is enabled;transfer, whenmultiworkspace-v1ordetached-transfer-v1is enabled.
Action validity is deliberately narrow:
continueandoverride_stepapply tofailedorpaused;set_priorityapplies only tosubmitted,ready,waiting,paused, orfailed;an operator
pauseapplies only tosubmitted,ready, orwaiting;relocateandtransferapply only to the quiescent states permitted by their extension;cancelmay target any nonterminal state and uses the explicit fencing and process-termination procedure of Cancellation for a live attempt. A secondcancelof a job already incancellingis not an error and changes nothing: the first one is still being carried out.
continue and override_step remain subject to job budgets unless the request
contains an authorized, auditable budget change under site policy. Because
job.json is immutable, such an override lives in the operator journal frame,
not by editing the job definition. They are additionally refused, without
force, for a job that a decided join already consumed; see
Reviving a child a decided join consumed.
In particular, set_priority and operator pause MUST NOT rename a claimed,
running, or committing marker behind its owning manager. An operator who
must stop live work uses cancel; a pause can be requested after that work is
released or fenced.
A manager claims the request by rename, verifies the exact expected current
marker, appends the new journal frame, and renames that marker. It resolves the
target by probing the finite state set at the request’s exact placement, the
same clean pre-release break the join ladder makes, and never by scanning the
workspace from a scheduling tick; a request that carries no placement is a
protocol error, claimed and quarantined as malformed input rather than allowed
to trigger a global lookup. A delayed request cannot apply to a newer state
because its expected generation no longer matches. All request-induced marker moves use verified transitions. A manager
whose live transition loses to cancellation rereads the current marker and
stops the fenced attempt; it does not infer ownership merely from an errno.
The result is written to the shared journal. The transient request file may be removed after retention policy permits; there is no per-job request directory.
A claimed request lives in requests/claimed/<manager-id>/ until the manager
that claimed it has decided about it: a request that was applied is removed, and
one that can never become actionable is retired.
A request that has been claimed and can never become actionable — its expected
generation or record reference no longer matches, the job moved while the
request was being applied, or it asks for something the protocol refuses such as
reviving a job a decided join already consumed — MUST be retired rather than
left where it will be read again. Retiring means writing a retirement record and
then moving the claimed file to requests/retired/; a retired request is never
rescanned. The record sits beside the request under the request’s own name plus
.retirement and is one object:
{
"format": "httk-workflow-retired-request",
"format_version": 1,
"request": "3f2b0a1c-6c2e-4a2a-9a94-1b7c3e5f0d21.json",
"manager_id": "b0d1e2f3-4455-6677-8899-aabbccddeeff",
"reason": "the job is at generation 7, not the expected one",
"retired_at": "2026-07-26T09:12:44.512314Z"
}
Both the retired request and its record are transient evidence for an operator, not protocol state: nothing reads them back, and “Retention gates and always-safe collection” below collects them after a month. This is distinct from two neighbouring cases that MUST NOT be retired:
a request whose job is served by a runner backend this manager does not serve is left in
requests/ready/for a manager that does serve it. A manager that has decided this about a request SHOULD remember the decision rather than reread the file on every pass;a request that cannot be applied right now — a held maintenance lock, an unavailable workspace — stays actionable and is simply retried.
A request that violates the protocol, including one naming a job that does not exist, is quarantined as malformed input rather than retired.
Manual continuation preserves failure history. In persistent mode it reuses the
declared workdir in place; the new attempt context records
is_restart: true, its cleanliness, and attempt_reason: "manual_continue".
No file import or transaction is required. In isolated mode, selected retained
files may instead be imported into the new workdir; if they also become
committed data/, that contribution uses a transaction.
Required outcome-processing order¶
For a valid current outcome, a manager:
validates job, activation, and attempt, plus expected data generation when a transaction is present;
appends a committing frame;
renames the exact running marker to committing, fencing the attempt;
applies or verifies the transaction, if present, and in the durable profile synchronizes every replayed destination and the directories it touched;
registers or verifies every child, synchronizing each published payload and marker’s directory in the durable profile;
computes failure or join information;
appends and synchronizes the destination state frame;
renames the exact committing marker to the destination;
performs optional cleanup later.
Steps 4 and 5 synchronize before steps 7 and 8, so a durable workspace has the
committed data and the registered children on storage before the marker rename
that claims them: a power cut can never leave a marker out of committing that
names a transaction its storage only half received.
Interruption recovery follows directly:
Interruption point |
Recovery |
|---|---|
Before marker submission |
No job was submitted; placed orphan may be retried or collected. |
In |
Revalidate; move the same marker to ready or to failed with |
Before |
Ignore temporary outcome; retry under policy. |
After outcome publication, before committing |
Apply that outcome; do not rerun the step. |
While a transaction is replayed |
State remains committing; infer completed operations from source, destination, trash, and digests. |
While children are registered |
Verify existing children and register the missing set. |
After destination frame, before marker rename |
Frame is prepared but not current; replay and rename. |
After marker rename |
New state is already authoritative; cleanup is optional. |
Old fenced process publishes late |
Never apply it. |
Before recovering a stale claimed or running job, a manager MUST inspect its named attempt-control directory for a valid published outcome. A committed decision must not be mistaken for an abandoned attempt.
Task-manager startup and recovery¶
A manager:
discovers explicit and watched workspaces and validates each
format.json;rejects duplicate workspace IDs and unsupported enabled extensions before mutation;
creates a manager record, fresh writer-incarnation journal, and heartbeat in each attached workspace;
resumes markers in
committingand, when enabled,relocatingandtransferring;resumes markers in
cancelling: every one of them names a fenced attempt that still has to be stopped and verified, whether this manager fenced it or inherited it from one that died mid-cancellation;examines possibly abandoned claimed and running markers;
evaluates waiting joins, including cross-workspace references when enabled;
handles submitted jobs and operator requests;
claims eligible ready work in pool, capability, priority, and resource order;
continues watching for workspaces being attached, renamed, or detached.
It does not need to reconcile a separate state index. Listing state/ is
listing the authoritative scheduler state.
A high-scale implementation SHOULD:
scan only non-terminal state placement prefixes relevant to its configured projects or job set;
keep an in-memory job-key-to-marker map while running;
process arbitrary placement subtrees incrementally rather than repeatedly scanning every attached workspace;
use filesystem notifications only as hints, since notifications may be lost;
workspace task-manager query caches outside per-job directories.
Discovering the globally highest-priority ready job requires enumerating the
ready tree. Incremental scans MAY therefore cause long-lived operational
priority inversion, especially after cold start; priority is a preference
rather than a strict global ordering guarantee. This is not a gap an extension
should close by adding directory levels — see the withdrawn priority-bands-v1
above — and no scan strategy changes claim correctness.
A manager MAY restrict every scan to a set of placement prefixes, the same kind of deployment policy by which it advertises pools and capabilities, so that disjoint managers divide a large workspace without walking each other’s trees. This is a restriction a manager places on itself and not a protocol change: placement values remain project-owned semantics that the engine only validates and filters on, overlapping assignments stay safe because the marker rename still arbitrates a claim, and a manager records its assigned prefixes in its manifest so a diagnosis can report when a live manager’s prefixes exclude a job’s placement. A manager with no assignment scans the whole workspace.
Workspace check and marker repair¶
A marker that no longer resolves to its journal frame is the one form of damage
a manager cannot route around: the marker is authoritative, and everything
beyond its kind, priority, and generation lives in the frame. A workspace check
walks every marker of every state kind and verifies that its record reference
resolves, within the configured visibility deadline, to a readable frame whose
checksum verifies and whose workspace_id, job_id, job_key, kind, and
state_generation agree with the marker name. The init reference of a
submitted marker resolves to nothing by definition and is verified as such.
Each unresolved marker is reported with a stable problem code: missing_segment,
short_read, checksum_mismatch, length_mismatch, trailer_mismatch,
reference_mismatch, invalid_header, undecodable_frame,
invalid_record_ref, identity_mismatch, or unparseable_name for a
marker-shaped entry whose basename cannot be interpreted at all.
Repair is optional, separate, and conservative. Because the frame that holds
previous_record_ref is precisely the unreadable one, a repair cannot follow
the chain of the job; it walks the journal segments instead, collects the
readable frames naming that job, and adopts the newest one whose
state_generation is strictly older than the marker’s. Adopting a frame at
or beyond the marker’s own generation is forbidden: such a frame is either the
damaged one or a transition that was appended and never committed by a rename,
and publishing it would invent state no marker ever carried. The repair then
appends one ordinary state frame with reason: "fsck_repair", whose
previous_record_ref is the recovered frame and whose members are carried
forward from it, keeping the marker’s own kind, placement, and priority, and
renames the marker onto that frame at the next generation. A repair therefore
adds history rather than rewriting it, and the job becomes loadable and
schedulable again.
A repair MUST NOT touch a claimed, running, committing, or cancelling
marker whose owning manager — identified from the readable frames of that job —
is still heartbeating within its lease. Such a marker is reported only; its
manager owns the transition that follows. cancelling belongs in that set for
a reason of its own: the marker is the fence a live manager put in place and
is still acting on, and re-pointing it at an older frame would take away the
attempt identity that manager needs to finish stopping and verifying the
process. A marker with no readable older frame is
unrepairable and is likewise reported, and may be moved into
.httk-workflow/quarantine/ only when an operator asks for that explicitly.
Garbage collection and compaction¶
The following may be collected under the explicit retention policy the
workspace publishes in policy.retention:
unpublished workspace temporary entries;
placed payload directories that never reached submitted state, except sealed transfer bundles;
journal frames not referenced by any marker or history chain;
incomplete outcome directories;
obsolete attempt-control directories;
abandoned and completed isolated workdirs;
transaction trash after the destination marker transition;
retained diagnostic application files.
That list is permissive: it bounds what a conforming collector may touch, not
what one must. The httk workflow workspace gc implementation collects the
subset tabulated in “Retention gates and always-safe collection” below, and
adds the retired transfer bundles and per-transfer receipts the
detached-transfer-v1 extension accumulates. It deliberately leaves isolated
workdirs, incomplete outcome directories, and payloads that never reached
submitted alone, because each of those is the only remaining evidence of a
job that went wrong.
Journal compaction operates on shared segments, not by creating files per job. It must preserve every record reference reachable from a current marker and the configured amount of history.
Terminal job.json, committed application data, the one state marker, and
required journal history are retained according to site policy. Age alone is
not permission to delete a non-terminal job.
A persistent workdir is application data, not attempt scratch. It is retained until an explicit job or site retention rule permits its removal, including after failure or manual continuation.
A payload containing .httk-transfer/manifest.json and its sealed marker is
not an orphan even though it has no marker in a workspace state tree. Generic
temporary/orphan GC MUST NOT collect, alter, or unseal it. Only an explicit
transfer import, abort, or transfer-specific retention action may do so.
Entries in .httk-workflow/quarantine/ are likewise outside generic orphan
GC. They are removed only by an explicit repair decision or a separately
configured quarantine-retention policy that preserves an audit record.
Empty placement-directory pruning follows the nonrecursive race rules in “State-marker rename.” Implementations should account for these directories: deep or job-unique placements can temporarily leave one empty hierarchy under several state kinds even though the marker inode count remains one per job.
No recovery operation begins by broadly deleting tmp, run, or unknown files.
Cleanup is separate from correctness.
Retention gates and always-safe collection¶
An absent member of policy.retention means keep. A collector MUST NOT
prune a category whose limit is unconfigured; a workspace whose operator has
said nothing therefore loses nothing.
Three categories are exempt because the entries in them cannot carry
information, and are collected whatever policy.retention says:
an empty placement mirror below a state kind, pruned by
rmdiralone;an entry still sitting in
.httk-workflow/tmp/or.httk-workflow/requests/tmp/more than 24 hours after it was written, since every publication renames its staging entry away within one operation;a request in
.httk-workflow/requests/claimed/<manager-id>/more than 30 days old whose manager is no longer heartbeating;a request in
.httk-workflow/requests/retired/, and its.retirementrecord, more than 30 days old. A retired request was already decided about and is never rescanned, so it is evidence for an operator and nothing else.
The remaining categories are gated as follows.
Category |
Gate |
Additional condition |
|---|---|---|
Attempt-control directory |
|
The job is in |
Transaction trash |
|
The job’s marker has reached a quiescent kind, so the destination transition has happened and no replay consults the trash again. |
Retired transfer bundle |
|
Below |
Import acknowledgement and import record |
|
Below |
Journal segment |
|
No current marker — nor the sealed marker of a bundle awaiting handover — references it, and its writer belongs to no manager heartbeating within its lease. |
Manager directory |
|
The manager’s heartbeat is expired and none of its writer’s segments were retained. |
The newest attempt-control directory of a terminal job is retained regardless of age: it holds the outcome, the failure breadcrumb, and the runner logs of the attempt that decided the job.
A collector MUST NOT prune the runner store. A runner is referenced by digest
from job.json and from transfer manifests, an attached workspace can gain a
job referring to one at any time, and the store is small; a future version may
add an explicit runner-retention rule, but generic collection never applies
one.
The cost of collecting journal history¶
Only the segment a marker’s record_ref points into is protected, not the
whole chain behind it. Collecting older segments of the same writer is exactly
what journal_days buys, and the consequence must be stated plainly: the deep
history of an old job goes with those segments. harvest and job log then
report that job’s timeline from whatever frames remain and set gaps — the
job’s current state, marker, payload, and terminal outcome are unaffected,
because they never depended on the older frames.
Crash safety and journaling of a collection¶
A collection MUST be interruptible at any instruction. It therefore removes
entries bottom-up, renames nothing, and rewrites no protocol state: the remains
of an interrupted removal are scratch that the next collection removes again.
Empty-placement pruning tolerates ENOTEMPTY and ENOENT as ordinary
outcomes, never as faults, because a concurrent transition recreating exactly
that path is expected.
A collection that removed anything appends one summarizing frame to the journal:
{
"format": "httk-workflow-gc",
"format_version": 1,
"workspace_id": "…",
"collected_at": "2026-07-26T09:12:44.512314Z",
"retention": {"journal_days": 30},
"removed": 41,
"bytes_reclaimed": 918273,
"categories": {"attempt_control": {"candidates": 12, "removed": 12, "bytes_reclaimed": 40960}}
}
This is not a state frame, so every reader that walks the journal for job history ignores it. A collection that removed nothing writes no frame at all, since opening a journal writer creates a writer directory and an empty collection must not create the garbage it came to collect.
Manual filesystem inspection¶
The layout is intentionally legible without a database:
state/ready/<arbitrary-placement>/is the runnable queue, withp000throughp999encoded in marker names and no priority directory level;state/running/is everything presently believed to execute;state/committing/is exactly the replay backlog;state/cancelling/is every attempt already fenced by a cancellation whose exit has not been verified yet;state/waiting/is the join backlog;state/failed/is the current broken-job collection;state/succeeded/is the finalized-success collection;.httk-workflow/quarantine/contains malformed entries requiring workspace repair rather than workflow scheduling;every marker begins with the optional human tag plus UUID job key;
the matching payload is at the same relative placement outside
.httk-workflow;a first placement component such as
project-17groups a project without imposing a protocol-specific hierarchy.
Operators may read all of these paths. They must use a workflow control command
rather than mv for state changes, because a correct transition must append
the matching journal record and validate the expected old generation.
An inspection tool should accept any of:
UUID;
workspace UUID;
exact job key;
marker path;
current payload path;
placement-prefix query;
tag query, returning all matches.
It follows the marker’s journal reference and backwards links to render state, step, retries, manager ownership, failure, children, and manual intervention history.
Security and hostile input¶
Task code is arbitrary code and should run under an appropriate OS, container, or batch-scheduler boundary. Managers additionally MUST:
open paths relative to already opened job/run directories where possible;
reject path traversal and forbidden file types;
avoid following symlinks during protocol validation;
limit JSON size, file count, nesting, journal-frame size, and payload size;
treat all runner IDs and paths as untrusted;
never interpolate a runner string through a shell;
verify that outcome job, activation, and attempt IDs match the current frame;
prevent a runner from writing immutable job metadata, the state tree, or the journal directly;
for transactional jobs, additionally prevent direct writes to committed
data/. Writes to the declared application workdir are allowed.
Persistent VASP restart example¶
A VASP workflow that wants traditional in-place execution can declare:
{
"workdir": {"mode": "persistent", "path": "run"},
"data": {"mode": "none"}
}
The behavior is then:
The first activation runs with
run/as its working directory,is_restart: false, andHTTK_WORKFLOW_UNCLEAN_RESTART=0.VASP writes
WAVECAR,CHGCAR,OUTCAR, and any other application files directly inrun/. The manager does not copy, rename, or interpret them.If execution disappears without publishing an outcome, the manager fences that attempt. After establishing that the old writer cannot still modify the workdir, it starts a new attempt in the same
run/.The replacement context says
is_restart: true,is_unclean_restart: true, and, for example,attempt_reason: "lease_lost". The step may validate or remove partial files, adjustINCAR, and resume fromWAVECARusing domain-specific logic.If the step publishes
advance, the next workflow step may also use the samerun/, but it receivesis_restart: false: this is its first attempt, not a restart merely because the workdir already contains files.A later operator
continueagain reusesrun/and is explicitly identified as a restart. No transactionaldata/output is involved.
For a POSIX shell step, the essential test can be as small as:
if [ "$HTTK_WORKFLOW_UNCLEAN_RESTART" = 1 ]; then
repair_or_validate_vasp_restart_files
fi
exec vasp_std
The context JSON remains the full, versioned interface; the environment variables are convenient projections for simple runners.
Worked example¶
This example deliberately uses isolated workdirs in a workspace with
transactional-data-v1 enabled. Job silicon-relax--J starts at prepare,
creates two calculations, joins them, and finalizes:
Submission publishes its one marker as
state/submitted/project-17/0/03a/<job-key>.p500.g0.init.A manager validates
job.json, journals ready recordR1, and renames the same marker tostate/ready/project-17/0/03a/...p500.g1.R1.Manager
M1journals claimR2and wins the rename to claimed.M1createsrun.A1, journals runningR3, renames the marker, and launchesprepare.preparepublishes one outcome containing transactionT1, childrenbranch-a--C1andbranch-b--C2, and anall_succeededjoin.M1journalsR4, renames running to committing, replaysT1, and publishes both child job directories and their one markers.M1journals waiting recordR5and renames committing to waiting.Children run independently and may restart without changing the parent marker.
Once both succeed, a manager journals
R6and moves the parent marker to ready foraggregate.aggregatepublishes an advance outcome and transactionT2. The manager moves through committing, applies all files, increments data generation, and queuesfinalize.finalizepublishes succeed. The same marker that existed at submission is renamed tostate/succeeded/....Completed isolated workdirs are later collected. The payload retains one job file and application data; its one external state marker mirrors its arbitrary placement, and history is packed with many other jobs in journal segments.
At every interruption point, the marker location selects exactly one recovery rule.
Relationship to httk v1¶
The httk-v1 runner backend and httk-v1-taskmanager compatibility executor
implement the following mapping for instantiated httk v1 task templates:
httk v1 |
This protocol |
|---|---|
|
One global state marker plus a packed state frame |
Task-manager |
|
Rename to |
Exact marker rename to claimed/running |
Stale directory |
Lease evidence followed by marker fencing |
|
Persistent |
|
|
Exit code 3 / |
|
Exit code 4 / broken |
|
|
Structured failure in packed history plus retained log |
|
Child bundle to placed payload plus one marker |
|
Idempotent adapter preflight replay before an httk₂ outcome |
Restart count in pathname |
Activation ID and attempt ordinal in state frame |
|
Audited manual continuation, reusing a persistent workdir or explicitly importing into an isolated one |
The adapter preserves the httk v1 ordering rule that a published
ht_finished/broken decision or pending atomic transaction is completed without
rerunning ht_steps.
httk v1’s restart counter was carried across steps but incremented only when a stale
running task was adopted; it did not bound an indefinitely advancing clean
workflow. httk₂ keeps the per-activation retry limit and adds optional total
attempt and activation budgets for that separate concern.
Join migration is intentionally not a pathname-for-pathname emulation. httk v1
waitsubtasks searched the whole nested task subtree and could notice
descendants that appeared later. An httk₂ join waits only for its immutable,
explicit child set. A child that publishes detached grandchildren may complete
without those grandchildren, so a migrated workflow that requires subtree
completion must make the child join its own descendants before succeeding or
name the additional jobs explicitly in the ancestor’s join.
The shipped adapter makes each discovered direct subtask an explicit child.
Each such child applies the same rule recursively, so ordinary nested httk v1 task
trees retain subtree completion. It uses all_terminal, rather than
all_succeeded, because httk v1 resumed a waitsubtasks parent once no descendant
remained in an active state; broken descendants did not keep it waiting.
Legacy ht.task.*.<status> symlinks are derived operator views only. The httk₂
marker and journal remain authoritative.
The principal improvements are:
one authoritative, atomically moving state entry rather than a state plus a potentially stale index;
a constant one-marker state inode cost per job;
transition, failure, and event metadata packed into shared journal segments;
optional human-readable path tags without weakening UUID identity;
arbitrary project/shard placement and crash-safe relocation;
dynamic multi-workspace attachment for combining projects;
explicit attempt fencing and restart detection;
explicit child sets and join policies;
transactional replay with no permanent revision-metadata tree per step;
structured, durable manual and failure history.
The filesystem remains the interoperability layer. Its steady-state inode cost is a design property described by the arithmetic above, while capacity beyond the measured snapshot is an operational question for the target filesystem and should be evaluated with Scale benchmarks before a campaign is sized.