Skip to content

Board-driven work queue: promote board cards to first-class tickets #757

Description

@serge-ivo

What the board is today

buildInstanceBoard (workers/api/src/lib/board.ts:290) is the one place the board's shape is defined — shared by the console, MCP, and the agent's own tools. It reads at most BOARD_TASK_LIMIT = 1000 rows via mirroredRuntimeTasks (the D1 mirror instance_runtime_tasks, migration 0012), groups them with jobKeyForTask (normalized job URL, else the task id), and emits one card per job whose attempts[] is the other rows in that group:

// lib/board.ts:325-333
const byKey = new Map<string, RawTask[]>();
for (const t of tasks) {  const key = jobKeyForTask(task);  }

Two consequences worth stating plainly, because they are what this issue exists to change:

  • For a run-backed card, no ticket exists before the run. The card IS the grouping of runtime-task rows. A coding card's jobKey is derived from a coding-session id (codingSessionIdFromCardId, lib/board-runs.ts); an apply card's is the job URL. Nothing can sit on the board undispatched, because there is no row until something dispatched it.
  • attempts[] is derived at read time, not stored. There is no run→ticket foreign key anywhere. buildInstanceBoard re-groups on every read, and the console polls it every ~2.5s.

board_items (migrations 0036/0037) is not a ticket table. Its own header comment says so: it persists only "what the automation can't know" — the human's status override — plus a title/subtitle/url snapshot so a moved card survives its runs being cleared.

Tickets already exist, but not as a first-class entity. POST /v1/instances/:id/tasks/direct (routes/instances-tasks.ts:326) and the create_ticket registry tool (lib/tool-registry.ts:490) both create a card before any run, with type: "ticket", a title, description and reasoning, and no runner involvement at all — the route never calls a runtime. lib/actionable-ticket.ts lets such a ticket carry {action, config, params} from the trigger vocabulary (run_pipeline, insert_record, add_knowledge, create_task, run_browse), default status needs_approval, executed by POST /tasks/:taskId/run through executeTriggerAction.

But those tickets are rows in instance_runtime_tasks — a table whose own header reads "Durable PAGS mirror of FAGS runtime task snapshots and task events". They share identity space with runner tasks, have no dedicated schema, and carry no stored relation to the runs they produce. This issue is about widening that into a real ticket, not inventing one.

1. Promote the ticket to a first-class entity

A durable ticket that:

  • is created by a human or an agent, both writing through one path;
  • exists before any run — including for work that would today only appear once a run started;
  • has runs attached as stored attempts, so the ticket→run relation is a fact in the schema rather than a read-time Map keyed on a string.

The existing attempts[] lets the schema widen rather than be replaced. BoardItemView.attempts (lib/board.ts:100) is already {id, status, updatedAt}[] and every consumer — the console BoardTab.tsx, MCP groupBoard, reconcileCodingCard — reads it as an opaque list. Filling it from a stored ticket_runs relation instead of from the byKey grouping is a change of source, not of shape. That is the migration path: keep the read model, move where the rows come from, and let jobKeyForTask grouping remain the fallback for tickets that predate the table.

Whether the durable form is its own tickets table or a clearly flagged ticket type inside instance_runtime_tasks is an implementation call. A separate table is cleaner (identity, constraints, and the run relation stop being borrowed from the runner's mirror); the flagged-type route is cheaper and keeps buildInstanceBoard reading one substrate. Either way the read model above must not change shape.

2. Scope: every agent instance, not just coders

This is a platform board-layer concern and must not couple to git, coding sessions, or repos. Partly true already — columns resolve from agentCapabilities(...).boardColumns with a per-instance override (boardConfigForInstance, lib/board.ts:44), and coding sessions are one card type among browser.task, job.apply_agent and plain ticket. State it as a design invariant so the queue does not quietly become a Coder feature: an apply agent, a pipeline agent and a cloud-only chat agent must all be able to hold a ticket and have it picked up.

3. Authority and budget are decided at the ticket

Budget. Today spend is bounded per account and per delegation tree only — account_budget_limits (migration 0113) resolving through lib/delegation-budget-store.ts, whose ceilings are DAILY_CEILING_MICROS / DAILY_TOKEN_CEILING with per-tree limits from openBudget. Nothing scopes spend to one card. A ticket should carry its own budget so spend is bounded and visible per unit of work: a runaway ticket exhausts its own allowance and parks, rather than eating the account's daily ceiling and taking every other ticket down with it.

Authority. Nothing on a ticket records whether the agent may start it unprompted. RUNNABLE_STATUSES in lib/actionable-ticket.ts:42 (needs_approval, queued, blocked, needs_human, failed) gates what may be approved — not who may start it. A ticket needs an explicit authority field: may the agent pick this up on its own, or must a human release it? Today the only expressible answer is the approval gate, which is all-or-nothing and human-driven.

Both belong on the ticket because both are decisions about this piece of work, and both are invisible if they live only at the account level.

4. Runs stream progress onto the ticket

The ticket should become the durable document of the work — what was attempted, what happened, what is left. This is a different mechanism from a status column flip, and neither substitutes for the other.

Today there is nothing to read back:

  • Detail is re-derived on every read. taskDescription (lib/board.ts:271) returns the task's own description, else output.detail, else result — whatever the latest attempt happens to carry now. Nothing accumulates.
  • The thread is human Q&A only. ticket.question / ticket.answer events (lib/ticket-chat.ts, indexed by migration 0088) are the owner asking about a card and the agent answering. Run progress is not written there, and threadTurns deliberately counts only latestTaskId.
  • Coding cards reconcile, but do not record. reconcileCodingCard (lib/board-runs.ts) settles a card against its runs at read time; the comment there is explicit that this "cannot be a write-through".

5. needs_human and blocked are inviolable

The queue must never pick up, retry, or advance a ticket sitting in needs_human or blocked. Those columns mean a person is the blocker, and a queue that advances them converts "waiting for you" into "done without you".

Note the existing shape this must respect: FINISHED_STATUSES (lib/board.ts:576) already excludes blocked from bulk clears with the comment "needs-you, kept active" — but RUNNABLE_STATUSES in actionable-ticket.ts includes both blocked and needs_human as approvable, because a human approving is exactly the unblocking act. The queue is not a human, so it needs its own narrower set, not a reuse of that one. Reusing RUNNABLE_STATUSES here is the obvious mistake and would be silent.

6. Yield to a red deploy; keep one active session per repo

A red deploy on main stops the queue. Build state is already readable without new plumbing: latestHostedBuild (lib/hosted-repo.ts:198) backs both the Coder's /coding/builds and the generic GET /v1/instances/:id/deploy-status (routes/instances-deploy.ts), and lib/deploy-watch.ts already sweeps for it. Picking up new work while main is broken means stacking changes on a tree nobody can verify.

One active session per repo stays. For coding instances, getActiveSessionForRepo (lib/coding-session-open.ts:411) is enforced at the DB layer — createSession throws on the race and the loser reattaches to the winner (coding-session-open.ts:442). The queue must go through the same path, not around it: two engines in one checkout is a corrupted working tree.

7. Default off, per-instance opt-in

Ships disabled, enabled per instance by the owner. PAGS hosts the agents that repair PAGS, so an autonomous queue that defaults on is a queue that can start unattended work on this platform's own repo on upgrade day. Opt-in also makes the blast radius of the first version exactly one instance the owner chose.

Relationship to #682

#682 (optionally back a ticket with a GitHub issue, read-only + cached) touches the same ticket model and would fit naturally on a first-class ticket — the issue number is one more stored field.

It is NOT a prerequisite, in either direction. #682 is a display/provenance concern; this issue is about the ticket's identity, authority, budget and run history. Neither blocks the other, and #682 is labelled P3: later. Whoever picks up either should confirm the current shape of the ticket model directly rather than assuming the other has landed.

Not verified

  • Whether a first-class ticket should be its own table or a flagged type in instance_runtime_tasks — argued above, not decided; it needs a look at how much of mirroredRuntimeTasks' behaviour (hidden flag, stale-while-revalidate, runner reconciliation) a ticket actually wants.
  • The shape of the per-ticket budget reservation against lib/delegation-budget-store.ts — that store's reserve() is tree-scoped and it is not established here whether a ticket becomes a tree, sits inside one, or needs a third scope.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3: laterDesign, strategy, or deferred pending a decision or demandbackendBackend / Worker / API workboardThe kanban surface a non-technical subscriber actually reads (lib/board.ts, not frontend)coordinationAgent coordination / delegation primitivesenhancementNew feature or requeststrategyProduct strategy / focus

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions