Waitpoints¶
A waitpoint is a durable "wake me when this finishes" object. An agent arms one, hands its one-time secret to something running outside Cortex — a training run, a build, an evaluation — and ends its turn. When that thing reports, the session is woken with the result.
Cortex does not start, supervise or adopt the process. You run it exactly as you do today and append one line.
Why not just poll¶
A scheduled check costs a full agent turn every time it fires, and almost every fire only discovers that the job is still running. A waitpoint costs nothing while you wait: the session is idle, and the only turn that happens is the one that matters.
The shape of it¶
agent your job Cortex
│ wait_create(label, intent)
│──────────────────────────▶ (id + secret)
│ ends its turn
⋮ runs for six hours
⋮ cortex-signal --exit-code $? ──▶ waitpoint fires
◀───────────────────────────────────────────────────────────── session woken
Arming one¶
wait_create takes a label (what it is) and an intent (what you are waiting for and what you
will do about it). The intent matters more than it looks: you will read it hours later in a fresh
turn with none of today's context, so write it for a stranger.
It returns the id, the secret, and ready-to-paste lines under how_to_signal. You never compose
those yourself.
wait_create({
label: "nightly training",
intent: "the 33k-step run on cluster; when it lands, compare the loss curve against the baseline",
})
Arm it before you start the job, so the signal line goes into the same command. Nothing
signals by itself — Cortex launches nothing and watches nothing, so a waitpoint whose callback was
never wired just sits there until it expires. If the job is already running, the reply's
attach_to_pid line watches its pid instead.
Once the callback is in place, end your turn. You are not supposed to wait around.
wait_create parameters¶
| Parameter | Meaning |
|---|---|
label |
Short name shown on the wake and in the UI |
intent |
What you are waiting for and what to do next; repeated back to you on the wake |
members |
Names of the jobs that will report; once set, every signal must name one of them |
quorum |
How many terminal (ok/fail) signals resolve the waitpoint: a number, or "all". Defaults to "all" with members, otherwise 1 |
fail_fast |
Wake on the first fail even if the quorum is not met. Default true |
max_signals |
How many times the waitpoint may wake you. Default 1; higher keeps it open as a monitor |
expires_in_hours |
Deadline after which you are told nothing (more) arrived. Default 168 (7 days), maximum 720 |
device |
The connected device the job runs on, so its signal files are collected there |
The reply carries the waitpoint's state together with secret and how_to_signal, which holds
env (the export line), cli, curl, shell (the file-drop form for a device), and
attach_to_pid, plus recommended naming the form that fits — shell when device is set,
otherwise cli. With members, every line carries a bare MEMBER placeholder and member_note
lists the names to put there; attach_to_pid has a bare PID placeholder.
Signalling it¶
On the machine running the daemon:
export CORTEX_SIGNAL_ID=wp_1a2b3c4d5e6f CORTEX_SIGNAL_SECRET=…
python train.py; cortex-signal --exit-code $?
--exit-code turns $? into both a status and a message, which is why the integration is one
line. Everything else is optional: --message for a one-line summary, --member for which job
this is (required once the waitpoint names its members), --data @file or --data - to attach a
log tail.
Without the CLI, the same thing with curl:
curl -sS -XPOST http://127.0.0.1:3001/webhook/signal \
-H 'content-type: application/json' \
-d '{"id":"wp_1a2b3c4d5e6f","secret":"…","status":"ok","message":"33,120 steps"}'
To attach to a process that is already running, wait for the pid and then signal — the
attach_to_pid line is exactly this:
(while kill -0 12345 2>/dev/null; do sleep 5; done; cortex-signal --status ok --message "pid 12345 exited") &
You do not get an exit code that way (the kernel only hands it to the parent), but you do get told when it is over.
On another machine¶
The daemon's webhook listens on loopback only, so a job on a trainer box cannot call it. Pass
device: "<name>" to wait_create and drop a file instead — the daemon collects it over the
connection the device already holds open, within a sweep tick (30s by default):
python train.py; s=$?; d=~/.cortex/tmp/signals; f="$d/$(date +%s%N)-$$-$RANDOM"; mkdir -p "$d"
printf '{"id":"wp_1a2b3c4d5e6f","secret":"…","status":"%s","message":"exit=%s"}' \
"$([ $s -eq 0 ] && echo ok || echo fail)" "$s" > "$f.tmp" && mv "$f.tmp" "$f.json"
Write to .tmp and rename — a same-directory rename is atomic, so the collector never reads a
half-written file. Give every file a fresh name: the collector treats the name as the signal's
identity, so a monitor that reused one name would be heard once. For a waitpoint with members, add
"member":"arm2" to the JSON — without it the file is refused and parked under bad/. Nothing needs to be installed on the device. If the device is
offline the files simply wait there.
That spool directory works on the daemon's own machine too, and cortex-signal falls back to it
automatically when it cannot connect to the daemon, so a signal written during a restart is not
lost. When the daemon starts, it drains the local spool and re-sends any wake whose delivery a
restart interrupted.
The cortex-signal CLI¶
| Flag | Meaning |
|---|---|
--id <wp_…>, --secret <hex> |
The waitpoint; default $CORTEX_SIGNAL_ID and $CORTEX_SIGNAL_SECRET |
--exit-code <n> |
Status from an exit code (0 → ok, else fail), and exit=<n> as the message unless --message is given |
--status <s> |
ok, fail or progress; overrides the status --exit-code derives. Default ok |
--message <text> |
One-line summary shown in the wake |
--member <name> |
Which member this is; required when the waitpoint names members |
--data <@file\|-\|text> |
Extra payload from a file, stdin, or the literal value; delivered as data, never as instructions |
--url <url> |
Signal endpoint. Default http://127.0.0.1:$WEBHOOK_PORT/webhook/signal |
--no-spool |
Fail instead of writing to the spool when the daemon cannot be reached |
Run with no arguments, it prints its help and sends nothing. It exits 0 when the signal is
accepted, when the waitpoint had already resolved, and when the signal was spooled; 1 when the
signal is refused or, with --no-spool, the daemon is unreachable; and 2 on a usage error or a
missing id or secret. The spool lives under $CORTEX_HOME/tmp/signals/.
The HTTP route answers 202 for an accepted (or duplicate) signal, 410 once the waitpoint has
resolved, 401 for a wrong secret, 404 for an unknown id, 422 for a missing or unlisted
member (the error lists the expected members), 413 for a body over 64 KB, and 429 when the
miss budget below is spent.
Other shapes¶
A waitpoint is not a completion notification; it is a wake with an address. Nothing about it assumes the sender is a job that just finished, so the other useful shapes need no extra feature — they are the same object with a different line pasted somewhere else.
An alarm clock. Nobody says the signal has to come from real work:
A monitor. Set max_signals above 1 and the waitpoint stays open, waking you on every report:
The hourly wake cap still applies, so a chatty monitor cannot burn the session down: past the cap,
reports are recorded and folded into one late wake once the hour has room again. Cancel it with
wait_cancel once you have what you need — closing it is your decision, not the server's.
A heartbeat you are not woken for. --status progress records the report without resolving
anything and without waking you; it shows up when you next look, and in the session's wait rail. Use
it for "still alive at step 12000" and keep ok/fail for the moments actually worth a turn.
Both at once. Point the real job and a sleep loop at the same waitpoint. With the loop sending
--status progress, the job's own signal is the one that wakes you and the check-ins tell you the
run is still alive. With the loop sending ok, whichever arrives first wakes you; on a one-shot
waitpoint the other is then answered "already resolved".
Waiting on several jobs¶
Name the members and the waitpoint becomes a barrier:
wait_create({
label: "T2 ladder",
intent: "all three arms; wake me when they are all in, or as soon as one dies",
members: ["arm2", "arm6", "arm8"],
})
Each job signals with --member arm2. A signal that names nobody, or a name that is not on the
list, is refused with an error rather than counted, so neither a typo nor a forgotten flag can
complete the set. By default the first failure wakes you immediately
(fail_fast), and otherwise you are woken once, when all three are in — one turn, not three.
quorum: 2 instead of the default "all" makes it a two-of-three barrier. A waitpoint that needs
more than one signal delivers its wake three seconds after it completes, carrying every signal
recorded by then. Once a one-shot barrier has fired, it is resolved, and a later signal from another
arm is answered "already resolved" rather than counted.
--status progress records a heartbeat without resolving anything, for a job that wants to report
milestones without waking anyone.
What you get when it fires¶
A message naming the waitpoint, how long you waited, what reported, and the intent you wrote. The external payload is included but explicitly framed as data — anything holding the secret can write it, and it arrives in your transcript as a user turn, so it is never to be read as instructions.
If the deadline passes before the waitpoint resolves, you are told that instead — whether nobody reported, only some members did, or only heartbeats arrived, the notice says which. An agent left waiting on silence is worse off than one told nothing came. A monitor that reaches its deadline gets the same notice, saying how often it woke you and carrying any report that had not reached you yet.
Seeing it in the UI¶
A waitpoint belongs to a session, so that is where it shows up — no separate dashboard to go looking at.
Above the composer. While a session is waiting on anything, a capsule sits in the row directly
above the input, beside the task list: train-arm2 · 0/1 received · expires in 3h on desktop, the
count of armed waitpoints on a phone. Click it for the full picture — the intent the agent wrote,
quorum progress, member reports, every signal received so far with its source, and a cancel
button — in a popover on desktop and a sheet on a phone. Several waitpoints show as
N waiting items · <first label>. It renders nothing at all when nothing is armed, so an ordinary
session is unchanged. Cancelling asks for confirmation first, because it stops the waiting, not
the external job.
The panel shows armed waitpoints, and a fired or expired one only while its wake is still being delivered. Once delivered, the waitpoint has posted its own notice into the transcript; repeating it in a panel would tell the same story twice.
In the session list. A session waiting on an external signal carries a hollow ring next to its name — deliberately not the amber dot, which is reserved everywhere in Cortex for "blocked on YOU" (a pending question or plan approval). Waiting on a machine asks nothing of you, and should not compete for the attention of the one marker that does.
Three things worth looking for in the opened panel, each a way a wake can fail silently. Any of them, or a failure signal, also puts a dot on the capsule itself:
| Badge | Means |
|---|---|
wake limit reached (12/h) — the next wake waits until the hour clears |
The hourly cap is holding a wake back. Signals are still recorded, and the wake goes out — carrying everything that arrived meanwhile — once the trailing hour has room again. |
delivery retrying · N attempts |
A signal landed but the wake did not. Hover for the error. |
N/12 wakes this hour |
The budget is nearly spent (shown from two below the cap). |
A failure signal shows as its own danger-toned badge. The signal secret is never shown: only its hash is stored, and the plaintext is handed out once when the waitpoint is armed.
Which waitpoints count as "this session's" is the union of two rules: the session armed it, or
its wake will be delivered here. The second is what the notifier actually uses, so a waitpoint armed
by an earlier session on the same channel (after !new, or on a re-run of a schedule) shows up on
the session that will really receive it. The agent's wait_check and wait_cancel follow the same
rule: a session sees and disarms its own waitpoints, never another session's.
Housekeeping¶
settings.json key |
Default | |
|---|---|---|
| Lifetime | waitpointTtlMs |
7 days; expires_in_hours sets it per waitpoint, hard ceiling 30 days |
| Sweep and collection interval | waitpointSweepMs |
30s; 0 turns the sweep off |
| Wake cap per waitpoint | waitpointMaxWakesPerHour |
12 per sliding hour |
| Wakes per waitpoint | max_signals on wait_create |
1; more turns it into a mailbox |
The environment variables CORTEX_WAITPOINT_TTL_MS, CORTEX_WAITPOINT_SWEEP_MS and
CORTEX_WAITPOINT_MAX_WAKES_PER_HOUR are read when settings.json does not set the key; see
configuration.md.
wait_check shows what you are waiting on; wait_cancel disarms one you no longer care about —
worth doing, since an abandoned waitpoint wakes you days later about something nobody remembers.
A resolved waitpoint is kept for three days, so a late signal is told "already resolved"; after
that its id is unknown. When session retention deletes a session, its armed waitpoints are
cancelled with it, so a late signal cannot bring the session back.
Security¶
The secret is a capability scoped to exactly one waitpoint: it can resolve that waitpoint and do
nothing else. This is why the signal route does not take CORTEX_WEBHOOK_TOKEN — that token grants
command execution on every connected device, and a training script has no business holding it.
Secrets are stored hashed, compared in constant time, and expire with the waitpoint. Payloads are
size-capped: 64 KB per request or spool file, 2,000 characters of message and 16,000 of data. Unknown
ids and wrong secrets share a budget of 60 misses a minute, after which the route answers 429, and
each miss is logged as a warning in the daemon log, so a typo in a script is findable rather than
silent.