Developing
lnk-link-scheduler is Link Scheduler: subagents and schedules for any
harness that reads who a message is from. How to use it: Link
Scheduler.
Build and test
From tools/, in the public workspace beside the core:
cargo build -p link-plugin-link-scheduler
cargo test -p link-plugin-link-schedulerIts two programs
serve --state <dir> is the tool, an MCP server over stdio, and run --state <dir> --way-in <socket> the runner. No number is the
program's own: each comes from the agent's spec
(Scheduler Contract).
Their flags
Both take the caps (--per-day, --subagents, --hops, --late) and
the time zone its times are in (--zone, local or a zone's name,
which it sets as the process's TZ after checking the machine's zone
database has it). The runner also takes how long it waits on a firing
(--timeout), which Link reads from the agent's spec ([tool.scheduler] firing).
The runner as its agent
The runner fires as its agent alone (--as <agent id>): each firing is
a bridge's call (link_bridge), proved with the agent's key, read from
the machine's keyring (--keyring <dir>, else Link's,
~/.lnk/agents/.keys) at each firing, since the key is made at each
start. An agent that isn't running has no key, and its firing waits to
be tried again.
A teammate's bridge
A teammate's bridge takes a message from the runner only as this agent's, and only into its conversation with it or a team's they share.
A call's conversation
A call says which conversation it's made in, and how many messages led
to it, in its _meta (link.local/thread, link.local/hops), as Link
Harness sends them, never in arguments the model writes.
Its tools
A harness that keeps a conversation's tools says them too
(link.local/tools, their names): task's tools must be among them,
and left out, the subagent gets them all, so a narrowed conversation's
subagent is never wider. Without link.local/tools, a job has no list
(the subagent gets every tool, as its harness gives them), and task
refuses one.
The job's list
The job keeps the list (tools), and its firing carries it to the
bridge, which sends it with the task (Link-Tools); said as anything
but a list of names, it's read as none.
Scoped to it
A call is scoped to its conversation: list and cancel see the jobs
sent to it, the subagents it started (answer its thread), and theirs,
and the messages to teammates sent from it. list gives the soonest
100, and cut, how many more there are.
Teams
An agent on a team is given its ID (--agent-id), its teams (--team-id <name>=<id>) and their other members (--teammate <team>/<name>=<agent id>), and the runner each teammate's way in (--way-in-of <agent id>=<socket>).
send
send is offered only with teammates, to one of their names (or
<team>/<name> where two teams' members share one) or a team's, a job
each (kind send, to the two agents' IDs). Its thread is the
teammate's agent-<this agent's ID> (or team-<team ID>), and its
answer the conversation it was sent from.
Its firing and answer
The runner fires it at the teammate's way in, from agent:<this agent's ID>, its answer nowhere there, and keeps what it's answered as a job
of its own (kind reply, from agent:<teammate's ID>, one hop on)
to that conversation. So delivering the answer, failed and tried again,
never sends the message twice.
Where the jobs are kept
The jobs are kept behind a seam, JobStore (src/store.rs): put, get,
list, count, move between waiting and being fired, remove, when the
soonest is due, and the day's count, so a machine whose agent keeps its
state off it gives another. store::Memory keeps them in memory for
tests.
The locks
The rest of the store (runner.lock and post.lock) stays in the
state folder on the machine. Each lock is opened in place, never
through a link put at its name (O_NOFOLLOW), so whoever else writes
the state folder can't point the runner or a post at a file elsewhere.
The runner holds its shared lock until its firings end. The multi-agent
test observes that lock released before checking that a removed agent's
jobs stay queued while another agent's jobs continue to fire.
The file store
The store uses the shared file driver (store::Stored,
link_store_contract::store::file). schedule/jobs/<id>.json holds
each job, written aside and moved in place, so the tool and the runner
never see half of one, and schedule/day.json the day's count, the day
in its time zone.
firing/
schedule/firing/<id>.json is a job the runner took to fire, moved
there from schedule/jobs/ (a cancel meanwhile wins the move) and
back with its next time or its next try once the firing ends, or
removed. A subagent is at work while its task is in either folder. At
its start, a runner alone moves what's in firing/ back, each counted
as a try.
What it counts
The 1,000 jobs that may wait are counted by their files in schedule/jobs/,
through the store contract, and subagents by the jobs' kinds. A move
carries each job and the day's count through that same contract.
Through the store bridge
store::Bridge, --store-bridge <socket> on serve and run: on a
machine that runs the agent only while it answers, its jobs are kept
off it, by the machine's store bridge and the driver it runs, so a
sleeping agent's jobs outlive its machine and whichever machine it's
bound to next reads them. It needs --as.
Its calls
Each call is one of the store contract's job operations (store
drivers:
job_put, job_get, job_list, job_count, job_shift,
job_remove, job_next, job_day, job_set_day, job_mark),
proved as the agent --as names with its key from the keyring
(--keyring, else Link's), read at each call, as each firing is.
A job in the store
A job is its JSON, kept whole, its place and when it's due, so the store answers when the soonest is due without reading one, and moves a job between waiting and being fired whole, of two moves of one job one made, on any machines. The mark the runner watches is the store's, moved by each change to the waiting jobs.
A call that fails
A call that fails is said on stderr and read as none (no job, none listed, a move not made), as an unreadable file is in the folder. When the jobs are next due, which the machine is told, is an error instead, so a post never says none for a store it couldn't read.
The next machine
--state keeps only the machine's locks, so the first runner on the
next machine, alone on its runner.lock, moves back what the last was
firing when the agent slept.
tests/bridge.rs
It runs the store bridge against
link_store_contract::store::serve_bridge over lnk-sqlite, built in
the core: the jobs firing as in the folder, a second machine reading a
sleeping agent's, each agent's its own, and the tool and a wake on the
address driver through the program.
The runner
The runner looks at every job when the soonest is due, a minute at most, and else only at the store's change mark, once a second.
Its drivers
It fires through a driver: WayIn, the agent's bridge's way in (a Unix
socket, one JSON line each way, link_connection_contract::Firing and
Fired), or InProcess, a function, for tests and a harness running
it in its own process. The same tests run on these two and Cron.
Drivers that aren't resident
Cron holds another driver and isn't resident (Driver::resident):
run --driver cron fires what's due, waits for those firings to end,
and stops (wake). Addressed holds another too and isn't resident
either: its wake then tells the machine when the jobs are next due
(Driver::woken).
A runner for good
A runner for good (the service driver, alone or run --agents) ends
once a SIGTERM asks it to, as its bridge stopping it does, or once the
process that started it is gone, either looked at each second. Either
way it ends in order, the firings it waits on ended, as when it's taken
off run --agents' list.
None outlives its bridge
So none outlives its agent's bridge, however that ends (stopped,
killed, its terminal closed), and the next bridge's runner is the only
one. A woken one (cron, address) fires what's due and stops anyway
(tests/many.rs).
Runners beside each other
Each holds a lock on runner.lock in the state while it works. The
first, alone, moves what's in firing/ back; one started while another
still waits on a firing, as a minute's wake may, leaves that firing
alone.
One runner for many agents
run --agents <file> --zone <zone> fires for every agent <file>
lists (a JSON list, each agent's flags as run takes them alone), each
through its own way in, all in --zone, since the zone is the
process's. Link's bridge for every agent of a machine runs one a zone
(tests/many.rs).
Its list changing
It reads the file again when it changes: an agent added starts, and
one gone, or listed with other flags, stops once its firings have
ended, holding runner.lock till then, so a runner after it never
fires one of them again.
What it doesn't run
One in another zone, or with --driver cron, isn't run, and its log
says why. With none listed and none left firing, it ends.
The address driver
For a machine that runs an agent only while it answers: --driver address --socket <socket>, the socket the machine gives (on a machine
that packs agents, its scheduler.sock). tests/address.rs runs it
through the program, the machine a stand-in on a Unix socket checking
each proof.
Its post
Whenever the jobs change, the scheduler posts when they're next due
there (Address::post): a bridge's call (link_bridge::call_at)
proved as the agent --as names, with its key from the keyring
(--keyring, else Link's), as each firing is. Its action is {"next": <seconds since 1970>}, or {"next": null} with none waiting,
answered {} or {"error": "<why>"}.
When serve posts
serve posts after each call but list; a post that fails makes the
call an error that says the job is kept. Posts take turns on
post.lock, each reading the jobs once it's its turn, so the machine's
last word is the jobs' last state. A machine refusing a post (one
proved as another agent, say) fails the wake.
One wake
run --driver address is one wake: whatever woke the agent at that
time runs it once, it fires what's due through the way in, waits for
those firings to end, posts the next time, and returns. Nothing of it
runs between firings, and run --agents and cron install refuse it.
A teammate elsewhere
A message to a teammate on none of the machine's ways in
(--way-in-of) goes to the machine the same way, proved as this agent,
its action {"firing": {"to": <the teammate's agent ID>, "firing": <the Firing>}}, answered {} once the machine has taken it to carry
wherever that agent is (Address::send).
Its answer
Nothing waits for the teammate's answer, which comes back as a message of its own: its thread is the teammate's, as a teammate's answer is on a machine that holds both.
The cron driver
It's woken once a minute by the machine's calendar. Link's harness
plugin runs cron install, labelled by the agent's ID, when the
agent's spec says [tool.scheduler] driver = "cron", and the bridge
then runs no runner of its own.
cron install and cron remove
cron install --label <name> <run's flags> writes a line of the user's
crontab, ended by # lnk-link-scheduler <name>, or on a Mac a
LaunchAgent (~/Library/LaunchAgents) whose StartCalendarInterval is
every minute, loaded with launchctl (asked nothing with
LNK_SERVICES=off). Installing again replaces it, and cron remove --label <name> takes it away.
Its output and paths
Its output is added to cron.log in the state, and its paths must be
absolute, since the calendar starts it from your home.
Missed minutes
The calendar only wakes it: each job fires by its own time, in the
scheduler's zone. A Mac that slept is woken once for the minutes it
missed; cron starts nothing for them, and the next minute fires the
missed jobs as late says.
LNK_CRONTAB
LNK_CRONTAB names a program to stand in for crontab, on any
machine, as the tests do (tests/cron.rs).