Concepts¶
Loom is structured as sources → bus → subscribers. Sources (the Lithos event stream, vault file watchers, the GitHub poller) publish typed events onto an in-process async bus; subscribers consume them. The route-runner is a special claim-bound subscriber that owns a task's lifecycle from claim to result.json.
lithos-loom run (supervisor: one TOML, forks children, relays SIGTERM / SIGUSR1)
├── route-runner ← [[routes]] LithosEventStream → one RouteRunner per route
├── obsidian-sync ← [obsidian_sync] Lithos streams + vault watchers → projection / push handlers
└── github-watcher ← [github_watcher] LithosNoteStream + GitHubIssueWatcher → issue sync / PR-gate sweep
Routes and tag matching¶
A [[routes]] stanza is a claim-bound subscriber. A task matches a route when it carries every tag in match.tags (superset semantics — extra tags are fine).
[[routes]]
name = "story-develop"
command = "uv run python -m lithos_loom.plugins.story_develop --task-json {{task_json}} --work-dir {{work_dir}} --result-file {{result_file}} --repo {{repo}}"
max_runtime_seconds = 28800 # SIGTERM, grace, SIGKILL, then a `timeout` escalation
completes_task = false # default true; false = success raises a `pr` gate instead of completing
human_blocking = false # true = the claimed task still shows in the Obsidian inbox
[routes.match]
tags = ["trigger:story-develop"]
Token in command |
Replaced with |
|---|---|
{{task_json}} |
Path to a read-only JSON file: {"task": <Lithos task envelope>} (plus a resume block on a resume dispatch) |
{{work_dir}} |
<orchestrator.work_dir>/<task_id>/ — the plugin owns the tree |
{{result_file}} |
The path the plugin must write atomically on exit |
{{repo}} |
[projects.<slug>].repo for the task's metadata.project — one route serves every registered project. A task with no project, or a slug this host has not mapped, is released with a [BlockerFailed] finding and never run. |
Routes listen to lithos.task.created, lithos.task.updated and lithos.task.released. Because updated is treated as "re-evaluate the match", adding a trigger tag to an existing open task dispatches it without a daemon restart; a plugin's own end-of-run metadata write cannot re-trigger itself because the task is already in the runner's in-process claim set.
The repo ships three plugin entry points: story_develop (the real one, see story-develop), echo (a zero-cost smoke test that writes status="succeeded" and exits — wire it to trigger:echo to exercise claim → run → complete) and prd_decompose, whose body is a stub that raises NotImplementedError.
Readiness, claiming and admission¶
On a match the runner does not derive readiness itself. It asks Lithos's server-side ready frontier — lithos_task_ready narrowed to the route's tags and the task's project, with lithos_task_blocked as the other half of the partition when the page is full — so a task behind an open blocks predecessor, an unmet gate or a cancelled blocker is simply deferred. Undetermined answers are re-checked with backoff (60 s → 15 min) rather than dropped; nothing dispatches on a guess.
Dependencies are edges, not metadata
Lithos rejects metadata.depends_on / metadata.blocked_on (invalid_metadata_key). Parallelism is the absence of a blocks edge. project import builds the graph from indentation; the runner reads it only through lithos_task_ready.
Once ready, the runner claims via lithos_task_claim (Lithos guarantees collision safety across hosts), spawns the plugin, renews the claim periodically, and waits for result.json. Serial admission applies to PR-producing routes (completes_task = false): a project's ready stories are admitted only while its open pr gates are under [orchestrator].max_open_delivered_prs (default 1; max_open_delivered_prs_total, default 3, is the backstop that posts [AdmissionHeld]). Held stories are released by metadata.priority, then first-asked order (ADR 0012). Both dials can be overridden per project in the project-context doc's metadata; 0 = unlimited.
Multiple hosts can run route-runners against one Lithos: each has its own agent_id, its own TOML (so its own [projects.*] checkouts) and claims competitively. There is no cross-host coordination.
The plugin contract¶
A plugin is any subprocess that accepts three flags and writes one file:
result.json must be written atomically (temp file + fsync + rename) and validate against docs/result-schema.json. Required: schema_version (const 1), task_id, status (succeeded | failed | interrupted), exit_code.
{
"schema_version": 1,
"task_id": "…",
"status": "failed",
"exit_code": 1,
"run_id": "abc12345",
"rounds": 5,
"pr_url": null,
"error": { "category": "agent", "message": "round 5: NOT approved (max_rounds)", "retriable": false },
"escalation": { "reason": "max_rounds", "summary": "round 5: NOT approved (max_rounds)",
"brief": { "branch": "loom/us42", "rounds": 5, "cost_usd": 48.53 } }
}
What the runner does with status:
status |
Route default | Runner action |
|---|---|---|
succeeded |
completes_task = true |
lithos_task_complete; every task that completion unblocked is republished onto the bus so the next link in a chain dispatches immediately |
succeeded |
completes_task = false |
Raise a pr gate from pr_url (a gate task joined to the story by a waits_on_gate edge), record metadata.pr_gate_id, release the claim and leave the story open. A success with no pr_url posts a loud [Friction] — nothing else guards against a re-run. |
failed |
— | Raise a human gate (Needs human: <title>, metadata.escalation_reason from the escalation block or error.category), write metadata.loom_last_attempt:<route>, post [NeedsHuman], fire [notifications], release. The story is structurally blocked until the operator completes the gate (= retry) or cancels the story (= abandon). |
interrupted |
— | Release with no finding. With a resume.resume_after block (a provider usage limit) the runner records metadata.loom_resume:<route> and re-dispatches at that time, up to 3 times, then escalates resume_exhausted. |
| missing / unknown | — | Escalate as contract_violation. A plugin killed on max_runtime_seconds escalates as timeout. |
Only status, pr_url, run_id, error, escalation and resume are acted on today; artifacts, commits, spawned_tasks, metadata_updates, exit_code and error.retriable are schema-validated but ignored by the runner. retain_failed_workdirs = true (default) keeps a failed run's work dir for triage; successful work dirs are reaped. The loom_last_attempt:* and loom_resume:* metadata namespaces are runner-owned — plugins must not repurpose them.
Gates: how work waits for a human or a merge¶
Loom never parks state in private markers when Lithos can express it structurally. Two gate kinds, both task_type="gate" tasks joined to their story by a waits_on_gate edge, so the story is absent from lithos_task_ready until the gate resolves:
| Gate | metadata.gate_type |
Raised when | Resolved by |
|---|---|---|---|
| PR gate | pr |
A completes_task = false run delivers a PR; develop deliver delivers a stopped run's branch |
The GitHub watcher sees the PR merge: story completed first, then the gate, [GateResolved] posted. A PR closed unmerged or deleted converts into a human gate. |
| Needs-human gate | human (raised_by="loom") |
A run ends without delivering; a remediation budget is exhausted; a conflict cannot be resolved; a delivered PR dies | The operator completes the gate (tick in Obsidian, Lens, or lithos_task_complete) — that is the retry gesture; the route-runner's escalation resolver re-dispatches the story live. Cancelling the gate strands the story; cancel the story to abandon. |
lithos-loom gates lists every open gate with its type, the PR or escalation reason, the waiting story, and a health word (ok / orphan / malformed / waiter-gone / waiter-resolved). A delivered-but-unmerged PR's live state is derived onto the pr gate as reconciliation_state (awaiting_review · reconciling · behind · resolving_conflict · gate_failed · needs_human · ready_to_merge) by the watcher's sweep alone; Lens and the CLI only read it.
A restart replays every open task as created. A delivered story is deferred by its pr gate; a failed story is declined while its loom_last_attempt marker still matches an unedited task — edit the task (any write) or delete the marker key to re-dispatch.
The event bus¶
Each child owns one EventBus. Events are frozen (type, timestamp, payload) records; payloads are the Lithos envelope (tasks) or {id, title, path} (note bootstrap — handlers re-fetch what they need).
| Event | Source |
|---|---|
lithos.task.{created,updated,claimed,released,completed,cancelled} |
LithosEventStream — bootstraps from lithos_task_list(status='open'), then streams GET /events with Last-Event-ID resume; cursor persisted under <work_dir>/<child>/sse_cursors.json |
lithos.note.{created,updated,deleted} |
LithosNoteStream — bootstraps from lithos_list(path_prefix='projects/', tags=['project-context']) |
obsidian.task.{status_changed,priority_changed,due_date_changed} |
ObsidianFSWatcher (250 ms poll of tasks_file) |
obsidian.note.modified |
ObsidianDirWatcher (body-only hash of <projects_dir>/**/*.md) |
github.issue.seen |
GitHubIssueWatcher (one per issue per poll) |
Event names are stable pre-1.0 (renaming one is a breaking change). Bootstrap-replayed events carry origin="bootstrap"; the runner's own synthetic nudges carry resume, gate-resolved, ready-recheck or admission-recheck.
Subscriptions are fire-and-forget handlers declared in TOML, each with a bounded queue, per-subscription retry (attempts, exponential | linear backoff) and a [Friction] finding on persistent failure (on_persistent_failure = "friction" | "ignore"). Filters: a structural match.tags superset test, then an optional where Python expression over event / task. Actions are hand-wired by name in the hosting child (ADR 0007), not discovered:
| Action | Child | Effect |
|---|---|---|
obsidian-projection, obsidian-awaiting-review, obsidian-status-transition, obsidian-priority-changed, obsidian-due-date-changed, project-context-projection, note-push, task-archive |
obsidian-sync | See Obsidian bridge |
github-issue-sync, github-issue-push |
github-watcher | Auto-wired; not declared in TOML. See GitHub watcher |
noop |
any | Logs at DEBUG — useful for tracing a filter |
Finding prefixes¶
Every finding Loom posts starts with a stable bracketed prefix so operators and Lens can grep them:
| Prefix | Posted by | Meaning |
|---|---|---|
[Friction] |
any | A side effect failed past its retries, or a notable event (note-push conflict, a sink that could not fire) |
[NeedsHuman] |
route-runner, github-watcher | A human gate now blocks the story: reason, summary, run facts, gate id, the actions (and the develop deliver <run> command when a branch exists) |
[BlockerFailed] |
route-runner | Marker-only failure path: a misconfigured route, or an exit where the gate could not be raised |
[DevelopResult], [ReviewDispute], [BriefReview] |
story-develop plugin | The run's own outcome; a deadlocked coder/reviewer dispute; a brief recheck that appended facts |
[GateResolved], [DeliveredPRClosed] |
github-watcher | PR merged (story completed); PR closed unmerged / deleted (converted to a human gate) |
[PRConflicted], [MergeGateFailed], [ConflictResolved], [ExternalReview] |
github-watcher | PR maintenance on a delivered PR — see GitHub watcher |
[AdmissionHeld] |
route-runner | The project hit max_open_delivered_prs_total; dispatch is held |
[ManualDelivery], [ConvergePushed] |
develop deliver, develop converge-push |
A human-driven delivery or push, recorded on the story |
[ReopenRequested] |
obsidian-sync, github-watcher | A completed task was unticked in Obsidian, or its closed GitHub issue was reopened. Loom does not reopen tasks itself; this signals the intent. |
[LinkedIssueGone] |
github-watcher | The linked GitHub issue was deleted; pushes to it stop until the task is re-linked |
What is not implemented¶
Stated so you do not go looking: the prd-decompose plugin body; application of result.json fields beyond those listed above; the resolved project entry inside task.json; startup reclaim of stale claims (they age out via Lithos's claim TTL); hot reload of the TOML; a persistent event log; a containerised daemon. PRDs for queued work live under docs/prd/ in the repo and are not documented here.