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.
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 mostBOARD_TASK_LIMIT = 1000rows viamirroredRuntimeTasks(the D1 mirrorinstance_runtime_tasks, migration0012), groups them withjobKeyForTask(normalized job URL, else the task id), and emits one card per job whoseattempts[]is the other rows in that group:Two consequences worth stating plainly, because they are what this issue exists to change:
jobKeyis 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.buildInstanceBoardre-groups on every read, and the console polls it every ~2.5s.board_items(migrations0036/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 thecreate_ticketregistry tool (lib/tool-registry.ts:490) both create a card before any run, withtype: "ticket", atitle,descriptionandreasoning, and no runner involvement at all — the route never calls a runtime.lib/actionable-ticket.tslets such a ticket carry{action, config, params}from the trigger vocabulary (run_pipeline,insert_record,add_knowledge,create_task,run_browse), default statusneeds_approval, executed byPOST /tasks/:taskId/runthroughexecuteTriggerAction.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:
Mapkeyed 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 consoleBoardTab.tsx, MCPgroupBoard,reconcileCodingCard— reads it as an opaque list. Filling it from a storedticket_runsrelation instead of from thebyKeygrouping is a change of source, not of shape. That is the migration path: keep the read model, move where the rows come from, and letjobKeyForTaskgrouping remain the fallback for tickets that predate the table.Whether the durable form is its own
ticketstable or a clearly flagged ticket type insideinstance_runtime_tasksis 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 keepsbuildInstanceBoardreading 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(...).boardColumnswith a per-instance override (boardConfigForInstance,lib/board.ts:44), and coding sessions are one card type amongbrowser.task,job.apply_agentand plainticket. 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(migration0113) resolving throughlib/delegation-budget-store.ts, whose ceilings areDAILY_CEILING_MICROS/DAILY_TOKEN_CEILINGwith per-tree limits fromopenBudget. 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_STATUSESinlib/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:
taskDescription(lib/board.ts:271) returns the task's owndescription, elseoutput.detail, elseresult— whatever the latest attempt happens to carry now. Nothing accumulates.ticket.question/ticket.answerevents (lib/ticket-chat.ts, indexed by migration0088) are the owner asking about a card and the agent answering. Run progress is not written there, andthreadTurnsdeliberately counts onlylatestTaskId.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_humanandblockedare inviolableThe queue must never pick up, retry, or advance a ticket sitting in
needs_humanorblocked. 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 excludesblockedfrom bulk clears with the comment "needs-you, kept active" — butRUNNABLE_STATUSESinactionable-ticket.tsincludes bothblockedandneeds_humanas 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. ReusingRUNNABLE_STATUSEShere is the obvious mistake and would be silent.6. Yield to a red deploy; keep one active session per repo
A red deploy on
mainstops the queue. Build state is already readable without new plumbing:latestHostedBuild(lib/hosted-repo.ts:198) backs both the Coder's/coding/buildsand the genericGET /v1/instances/:id/deploy-status(routes/instances-deploy.ts), andlib/deploy-watch.tsalready sweeps for it. Picking up new work whilemainis 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 —createSessionthrows 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
instance_runtime_tasks— argued above, not decided; it needs a look at how much ofmirroredRuntimeTasks' behaviour (hidden flag, stale-while-revalidate, runner reconciliation) a ticket actually wants.lib/delegation-budget-store.ts— that store'sreserve()is tree-scoped and it is not established here whether a ticket becomes a tree, sits inside one, or needs a third scope.