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-scheduler

Its 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).