Skip to content

The Lane Board

joshdaugherty edited this page Sep 26, 2026 · 5 revisions

Describes robot-council/core v0.7.1.

The lane board at {web prefix}/dashboard/lanes groups the fleet's lanes by repository. Above the lanes it shows one meter per repository: that repository's open-issue count, and how far it has moved since the morning. This page covers the meters: what the number is, where it comes from, what "count unreadable" means, and how an operator sets up the GitHub App that supplies it. What a lane row shows (state, watcher, occupancy against capacity, On what) is in Presence and The Task Lifecycle. Since v0.7.0 a working lane lists every task it holds, each with its sub-label where it has one (#409).

What a meter shows

  • The count is open issues, with pull requests left out. It is GitHub search's total_count for repo:OWNER/NAME is:issue is:open (GitHubApp.php).
  • The line under it compares that count with 08:00 today, in robot-council.dashboard.timezone. It reads up N, down N, flat, or no baseline today. A scheduled command takes each repository's baseline once local time passes 08:00, from the latest reading in the stale window before 08:00, which is 07:00 to 08:00 by default. With no reading in that window, no baseline is taken, and the meter reads no baseline today all day (TakeBacklogBaselineCommand.php, #339). Only up is colored, because up is the bad direction.
  • A reading goes stale after robot-council.backlog.stale_after_minutes, 60 by default. Past that, the meter reads count unreadable rather than showing the last number it had (Backlog.php).

Where the count comes from

Core fetches it, every five minutes, through a GitHub App the deployment holds the key for (#383). It is the one thing core reads from GitHub, and it is display only. Nothing that frees a lane, places work, or changes a task depends on it; those learn from the webhook alone. That boundary is why this is an exception to the rule that core reads nothing from GitHub, and why it goes no further (CLAUDE.md).

Each run (BacklogFetcher.php):

  1. reads the repositories the lane board currently shows, at most 25 a run, those tried longest ago first;
  2. finds the App's installation on each repository's owner, once per owner per run;
  3. mints one short-lived token per installation, narrowed to Issues read, and caches it, encrypted with the application key, until five minutes before it expires;
  4. asks search for each repository's count, and stores one reading per repository.

The fetch is the last of the package's scheduled entries. It runs in the background and never overlaps itself, so a slow GitHub cannot hold back the coordination checks. A rate limit, or a second request in a row with no answer, ends the run early; the repositories it did not reach are tried first next time.

A session can still report a count itself with backlog_report. That is how the meters were filled before #383, and it still works, but no session needs to.

Why a meter reads "count unreadable"

A failed fetch stores no reading, never a zero. Each cause below leaves the meter unreadable once the last reading, fetched or reported by a session, is older than the stale window:

Cause What doctor's backlog fetch check says
No App is configured Passes. Nothing is fetched, and the meters show only what sessions report.
Only one of the two variables is set, the App ID is not numeric, or the key does not parse key unusable. Fails, and so does the github app check.
The App is not installed on the repository's owner no installation. Fails.
The installation does not include this repository, and it is private refused, typically with 422. Fails.
GitHub refused the token or the search refused, with the status. Fails.
GitHub answered, but with no usable installation, token, or count unparseable, with the status. Fails.
GitHub did not answer, rate-limited the run, or returned an incomplete search Undetermined. These clear on their own.
Something outside GitHub failed, such as the cache store error. Undetermined. The exception's class is in the log, not in doctor's output.
The scheduler is not running Undetermined, because no board repository has been fetched recently.

php artisan robot-council:doctor runs both checks: github app (configured, and does the key parse) and backlog fetch (per owner, is there an installation; per repository, how did its latest fetch go). Neither check calls GitHub, and neither prints a credential (GitHubAppDiagnosis.php).

Setting up the GitHub App

Do this once, as an owner of the organization that will own the App.

  1. Open the new-App form for that organization: https://github.com/organizations/ORG/settings/apps/new.

  2. Name and homepage. Any unique name. The homepage URL is shown publicly and GitHub never calls it, so point it at the repository rather than at your deployment. Leave everything under Identifying and authorizing users and Post installation empty.

  3. Webhook: untick Active. The App needs no webhook. The fleet's own webhook is separate and stays as it is.

  4. Permissions: Repository permissions > Issues: Read-only. GitHub sets Metadata to read-only itself. Grant nothing else.

  5. Where can it be installed? Choose Any account if the board shows repositories from more than one organization or account. A public App can be installed by anyone on their own account, but that gives them nothing from your deployment, which only asks about the owners of repositories on its own board. Only on this account is enough when every repository belongs to the owning organization.

  6. Create it, and note the App ID. It is a number shown under About, not the client ID that starts with Iv.

  7. Generate a private key. GitHub downloads a .pem file. Keep it out of chat, tickets, and the repository.

  8. Install the App on each organization whose repositories appear on the board, from the App's Install App page, choosing Only select repositories. Prefer that to All repositories: an allowlisted developer can name any repository as a session's repository, and the board then shows that repository's count to every allowlisted developer. A private repository left out reads count unreadable, because GitHub refuses the search. A public one may still be counted.

  9. Give the deployment both values, as secrets where the host supports them:

    Variable Value
    ROBOT_COUNCIL_GITHUB_APP_ID the App ID
    ROBOT_COUNCIL_GITHUB_APP_PRIVATE_KEY the key, base64-encoded onto one line: base64 < key.pem | tr -d '\n'

    A PEM pasted whole is also accepted, including one with its newlines written as \n, in the PKCS#1 form GitHub downloads or PKCS#8.

  10. Redeploy. On Laravel Cloud a new or changed secret takes effect only after a redeploy.

  11. Check it: run php artisan robot-council:backlog-fetch once rather than waiting for the schedule, then php artisan robot-council:doctor --only="github app,backlog fetch". Both checks should pass, and the board's meters show counts.

Later changes:

  • A new organization on the board: install the App there (step 8). Nothing on the deployment changes.
  • A new repository in an installed organization: add it to that installation's selected repositories.
  • Rotating the key: generate a new key, replace ROBOT_COUNCIL_GITHUB_APP_PRIVATE_KEY, redeploy, check with doctor, then delete the old key on GitHub.
  • Turning the fetch off: set robot-council.schedule.backlog_fetch to false. Otherwise, with no App configured, it does nothing.

A host running Laravel Telescope with its HTTP client watcher should add 'token' to Telescope::hideResponseParameters(). The response to minting a token carries the token in its token field, and Telescope would otherwise store it in the clear.

Known limits

  • Counts are GitHub search's, which answers from an index. A count can trail an issue opened seconds ago. A search GitHub marks incomplete is not recorded at all.
  • Token shape: GitHub's installation tokens changed shape, and v0.6.10 and v0.6.11 refused every current token as unparseable (HTTP 201). v0.6.12 accepts the current shape (#395).

Clone this wiki locally