Issue Router design
The Issue Router is an optional reconciliation loop that dispatches bounded Kanbus issue packages to a coding agent. Kanbus issues and event history remain the durable source of truth. The router decides when work may start and provides an isolated worktree and claim. Every provider turn is recorded as a durable router.conversation event before any optional structured result is interpreted. A result validator is therefore an automation aid, never a visibility gate.
The router is agent-neutral: Codex was the first adapter and OpenCode is the second (models on Amazon Bedrock and others). An adapter is one registry entry per runtime. The router does not ask a model to choose work, assign work, or perform review approval. Planning, package boundaries, capacity, retries, claim fencing, and GitHub lifecycle are deterministic.
Configuration contract
Projects without a top-level router: block keep normal Kanbus behavior. Router commands fail with exit code 2 and error: issue router is not configured. The router block is strict: unknown fields and invalid values fail configuration loading with exit code 1.
The initial configuration is:
router:
workflow:
pending: open
active: in_progress
review: review
blocked: blocked
terminal: [closed]
limits:
project_wip: 2
review_wip: 1
class_wip:
implementation: 1
provider_wip:
codex-default: 1
providers:
codex-default:
adapter: codex
command: codex
args: []
classes:
implementation:
providers: [codex-default]
forge:
provider: github
repository: anthusai/kanbus
base_branch: main
api_url: https://api.github.com
token_env: GITHUB_TOKEN
watch_interval: 30s
retries:
max_attempts: 3
workflow requires all five roles. terminal is a nonempty list of configured statuses. Workflow role names must map to distinct statuses, and every configured status must exist in the project workflow. limits.project_wip and limits.review_wip are required positive integers; review WIP cannot exceed project WIP. class_wip and provider_wip are optional maps of configured class/profile names to positive integers. Missing entries have no route-specific cap. retries.max_attempts defaults to 3. watch_interval defaults to 30s and accepts a positive duration in seconds, minutes, or hours.
The only initial adapter is codex. Each provider profile requires adapter: codex. command defaults to codex; args defaults to an empty list and replaces the executable's default prefix for controlled environments such as the fake adapter used by specs. Class provider lists are ordered and nonempty. An exact provider route is pinned to that profile; a class route tries the configured profiles in order when a new claim or takeover begins.
forge.repository is required and accepts one owner/repository value. provider defaults to github and no other provider is accepted in the first slice. base_branch defaults to main, api_url defaults to https://api.github.com, and token_env defaults to GITHUB_TOKEN. The token is read from the named environment variable; when it is unset or empty, the router falls back to the output of gh auth token (the same order in both runtimes). Credentials are never stored in .kanbus.yml.
The router uses existing top-level coordination.providers settings. [git] and [mqtt, git] are soft coordination: MQTT may improve claim visibility, but neither configuration guarantees exclusive starts. [mutex_api, mqtt, git] requests hard coordination. When Mutex API is first in the configured provider list, a router start requires a live Mutex API lease; falling back to MQTT or Git does not authorize an adapter start.
Watch mode polls on router.watch_interval. When MQTT coordination is available, it subscribes before claim publication and uses peer notifications to wake reconciliation before the next scheduled poll. If MQTT becomes unavailable, Git-backed router-state polling continues at the configured interval.
Shared router state and publication
Shared router events and router-owned issue status records are published to refs/heads/kanbus/router-state. The router creates publication commits in an isolated hidden Git worktree, keeping the user's checkout index and uncommitted files out of router commits. Router events and affected issue records are written by the router; the adapter cannot write Kanbus project data directly. Without an origin remote, this state stays in the local repository and cannot coordinate with other clones. If a configured origin cannot be reached, state publication fails.
The Python implementation keeps a hidden worktree for this branch. The Rust implementation currently creates a temporary detached worktree for each publication. Both target the dedicated state ref, but the worktree lifetime differs between implementations.
Routing and package boundaries
An issue opts into routing with exactly one of these labels:
agent-class:<class-name>selects an ordered provider profile list fromrouter.classes.agent-provider:<profile-name>pins the issue to that configured provider profile.
Labels are parsed as exact values. An issue with both label kinds, multiple routing labels, an unknown class, or an unknown provider profile is deferred with invalid_route. Human assignees are preserved and do not imply routing.
A tagged issue is one routed package. Untagged descendants belong to the nearest tagged ancestor. A tagged descendant begins its own package and is excluded from the ancestor's package. Package issue IDs are sorted by hierarchy traversal order, then issue ID for stable output. The adapter may propose transitions only for issue IDs in its package. The router validates that each transition is legal before applying it.
Definition of Ready and plan order
A candidate can start only when it is in the configured pending status, has a valid route, has no unresolved blocking dependency, is within its package boundary, and passes project policy. Recoverable active work and packages with requested changes are planned before new pending work.
Pending work sorts by the latest time the issue entered the configured pending status, oldest first. Ties sort by created_at ascending and then issue ID ascending. updated_at is not a scheduling key. For a class route, the selected profile is the first configured profile below its capacity limit. A provider route never moves to another profile.
Deferred candidates use these reasons, in precedence order:
pausedheldinvalid_routedependency_blockedpolicy_rejectedretry_backoffproject_wip_limitreview_wip_limitclass_wip_limitprovider_wip_limit
Issues outside the configured pending state and without recoverable active work are omitted from the plan. Project WIP counts every issue in configured active, review, or blocked statuses, including human-owned work. Review WIP counts every issue in review. Class and provider caps count router packages with those routes in active, review, or blocked status. Blocked and review packages consume WIP. These limits stop router starts; manual Kanbus transitions can exceed them.
kanbus router plan prints eligible rows and deferred rows in deterministic order. kanbus router plan --json prints a two-space indented JSON object with this fixed key order and shape:
{
"version": 1,
"enabled": true,
"paused": false,
"eligible": [
{
"issue_id": "kbs-101",
"route": {
"kind": "class",
"name": "implementation",
"provider_profile": "codex-default"
},
"package_issue_ids": ["kbs-101", "kbs-102"],
"pending_since": "2026-09-17T10:00:00Z",
"attempt": 1
}
],
"deferred": [
{"issue_id": "kbs-103", "reason": "provider_wip_limit"}
]
}
The JSON route.kind is class or provider. name is the class or profile named in the label. provider_profile is the profile selected for this attempt. pending_since is RFC3339 UTC. attempt is the attempt number that would start next. Arrays use plan ordering. The absent-config error is written to stderr and does not return a partial plan.
Execution and operator controls
kanbus router run --once performs one reconciliation pass and processes at most the first eligible package synchronously. It creates a run-specific worktree and branch, executes the configured Codex adapter, validates the result, records the accepted checkpoint and artifact references in router history, and opens or updates a GitHub pull request for a completed result when a forge is configured. Its summary is:
Issue Router run completed: started=1 completed=1 review=1 failed=0 deferred=0
The counters reflect that pass. A completed adapter outcome moves the package to review; a retryable failure increments failed; deferred counts the planned packages that could not start. A no-work pass succeeds with all zero counters.
kanbus router run --watch reconciles immediately and then repeats every watch_interval. Each cycle reads router history from the state branch, polls GitHub pull request state, and plans work. An MQTT peer notification can trigger an earlier cycle; an MQTT outage leaves the configured polling path in place. kanbus router stop asks the local watch process to finish its active adapter call, release its scheduler claim, and stop before starting more work. Watch process state is local to that clone.
pause and resume change the scheduler state for the current clone. hold and unhold change one configured class or provider profile in that clone. Control events are published to router history for audit, but the effective pause, holds, and watch-process state are stored in Git-private local state; another clone does not inherit them. A pause does not cancel an active package. A hold defers only matching routes. status reports the local running/stopped state, active/paused scheduling, active run count, and sorted holds. cancel <package-id> sends cancellation to the current adapter, blocks that package, and preserves the latest accepted checkpoint.
Both runtimes keep the active claim and adapter process identifier in Git-common local state. A separate kanbus router cancel invocation signals the child, records a durable cancellation request, and prevents the cancelled claim from publishing accepted results.
Agent result and publication contract
Durable agent conversations
Each routed package has an append-only, provider-neutral conversation stream on
the router-state branch. A record contains the provider, provider session ID
when available, claim/revision, lifecycle, worktree and branch, agent or system
message, concise command/test evidence, and a redacted raw-log payload. The
router records started before launching the agent and records the complete raw
turn before parsing its final answer. kanbus router recover <issue> surfaces
that preserved run without launching a replacement session.
If a session exists but validation fails, the router records a visible system comment and moves the package to Review with the branch and transcript intact. If launch fails before a session exists, it records the diagnostic and moves the package to Blocked. No provider may close an issue; completed or incomplete work is always reviewable.
The adapter returns one JSON object with these keys in this order:
{
"schema_version": 1,
"outcome": "completed",
"summary": "Implementation is ready for review.",
"issue_updates": [],
"issue_comments": [],
"checkpoint": {
"ref": "refs/kanbus/router/checkpoints/kbs-101",
"revision": 5
},
"artifacts": [
{"name": "test-report", "ref": "refs/kanbus/router/artifacts/kbs-101/test-report-r5"}
]
}
outcome is completed, blocked, or retryable_failure. issue_updates contains {issue_id, status} proposals only. issue_comments contains {issue_id, text} proposals; the router validates package scope and writes them through Kanbus's canonical comment mutation path after it accepts the result. Agents must not edit issue files directly in the isolated worktree. The router rejects an issue outside package scope and any transition that is not allowed by the configured workflow. checkpoint is either null or {ref, revision}. artifacts is a list of named refs. The adapter cannot publish a pull request, change the current claim, or move the package to a terminal state.
Every claim has a unique claim ID and a monotonically increasing logical package revision. A checkpoint or result is accepted only when both values match the current claim. The current claim fences issue transitions, checkpoint movement, artifact publication, branch push, and pull request updates. GitHub does not offer a transaction that can atomically test the Kanbus lease while creating a PR. The router therefore checks its exact claim immediately before and after the branch push and PR API call, and uses --force-with-lease compare-and-swap rollback if it loses the claim. A claim lost during an in-flight API request can leave a PR briefly visible; when remote branch state has concurrently changed, best-effort rollback may fail and requires operator inspection. Such an operation is not accepted into Kanbus router state. A stale claim may leave its temporary worktree but cannot move a durable router ref.
Checkpoint references use the refs/kanbus/router/checkpoints/<package-id> namespace. Both runtimes move the accepted checkpoint ref to the run commit and lease-push it to origin, allowing another clone to fetch and restore it. Artifact values such as refs/kanbus/router/artifacts/<package-id>/<name>-r<revision> are recorded as named references in router history; they are not materialized or pushed automatically, so consumers must not assume an artifact ref resolves unless the adapter published its object separately. A rejected or stale publication does not advance the accepted checkpoint or record the artifact as accepted.
Failure and recovery
Retryable failures remain active until the final allowed attempt. Attempt n waits min(30 seconds * 2^(n-1), 15 minutes) before retry. With the default of three attempts, delays before attempts two and three are 30 and 60 seconds. The next adapter request includes the latest accepted checkpoint reference, which another clone can fetch from origin. The third retryable failure transitions it to the configured blocked status and records maximum retry attempts reached. A structured blocked outcome blocks immediately without retry.
The execution lease detects a dead process. Structured progress and accepted checkpoints from the current claim reset the default 24-hour stale-ownership clock; human comments and empty heartbeats do not. A package with stale ownership may be taken over with a new claim and higher logical revision. The new adapter request receives the latest accepted checkpoint reference and can fetch it from the shared remote.
GitHub pull request lifecycle
For a completed result, the router creates or updates a pull request titled [<package-id>] <issue-title> on codex/router/<package-id>/r<revision>. Its body includes Kanbus package: <package-id>. The issue enters the configured review status. Opening or synchronizing a pull request keeps it in review.
The router polls GitHub during each watch reconciliation and consumes normalized events with schema version 1: event_id, kind, action, repository, number, head_sha, and merged; check-run events also include conclusion. Pull-request actions are opened, synchronize, requested_changes, approved, and closed. Check-run events use kind: check_run, action: completed, and a conclusion of success or failure. Event IDs are idempotent. Events for an unowned pull request or a repository mismatch are rejected.
Requested changes or a failed required check move the package from review to active, ahead of pending work; the next run updates the same pull request. A successful check leaves it in review. Approval alone leaves the package in review and is tied to that head SHA; a synchronize event invalidates approval for an older head. A merged event moves the package to a configured terminal status only when an approval for the current head was recorded. A closed, unmerged pull request moves the package to blocked. The router never merges a pull request.
Multi-router guarantees
Git-only and MQTT-plus-Git coordination are soft. Competing workers can both start the same package if they cannot see one another's claims. Once a newer claim is visible, revision checks reject stale checkpoint and result publication. MQTT does not provide hard exclusion; it supplies a low-latency early-wake signal, while an outage falls back to Git polling at the configured interval.
With [mutex_api, mqtt, git], the router requires a live Mutex API lease before starting a package. If Mutex API is unavailable, no adapter starts through a weaker provider. While the lease is live, only its owner may start the package; after expiry, another worker may take over with a new claim revision. Result fencing applies when the newer claim is visible to the publisher.
Specification status
features/router/ records the intended behavior contract for the first vertical slice (Codex, then OpenCode). Unit and feature tests cover selected router behavior. The offline integration harness exercises two connected clones against a disposable Git remote, including router-owned state publication; it does not simulate a network partition. Its optional live Mutex API and MQTT paths require explicit service configuration and should be described as validated only after an actual live run. There is no live GitHub or real-agent coverage in that harness. The generic coordination feature features/coordination/revision_publication.feature specifies revision-fenced reference publication. Adapters beyond Codex and OpenCode, automatic merge, LLM scheduling, provider cost budgets, and session-quota admission remain outside this slice and should be specified separately when approved.