What a run publishes — the monitor wire contract
Bobcat runs announce themselves. This is the publisher's side of that: the events a run emits, the transport it emits them over, the seams that produce them, and the environment variables that steer or silence the whole thing.
Decisions of record (2026-07-31), amended by the Bobcat/Stoat split (2026-08-09), by the console's move to Stoat (2026-09-18), and by its return to Bobcat as bobcat ui (2026-10-13, #524).
A record written while there were two lanes
Much of this page dates from when Bobcat also ran Gherkin .feature files on its own engine, through BobcatRunner. That lane was removed in 0.50 (bobcat#471). The wire contract is unchanged: projected specifications publish every event described here, through the runner adapters and the supervisor. Where a passage names BobcatRunner, the Gherkin lane or [TableGrammar], it is describing how the retired lane published, kept as the record of why the wire is shaped the way it is.
The receiver is bobcat ui
The console that receives these events -- the run board, the archive, the exports, the agent queries over them, the Event Model canvas -- is the Bobcat UI, src/Bobcat.Ui, served by bobcat ui. It lived in Stoat from 2026-09-18 and came back on 2026-10-13 (#524). The agent queries that were Stoat MCP tools are bobcat commands now; see Querying Runs from the Command Line.
The move changed no part of the wire. What a publisher speaks is what it always spoke:
Bobcat.Monitoring,Supervisor.PublishToMonitor,MonitorPublisher, theMonitor:*config keys, and the mirror records insrc/Bobcat/Monitoring/MonitorEvents.cs.BOBCAT_MONITOR,BOBCAT_MONITOR_URL,BOBCAT_RUN_ID,BOBCAT_RUN_TAG,BOBCAT_RUN_OWNER,BOBCAT_RUN_COMMAND,BOBCAT_RUN_COMMAND_FILE,BOBCAT_LIST_SPECS,BOBCAT_RESIDENT,BOBCAT_RUNNER_ID— every one a user-facing contract.CLAUDE_CODE_SESSION_IDis read too (issue #389) and is deliberately not aBOBCAT_*variable: Bobcat does not ask for it, it reads what an agent session already put in the environment.- Port 5525, the default address a publisher probes and the one
bobcat uibinds by default. It is a default, not the only target: Stoat listens on its own port and a run reaches both by naming both inBOBCAT_MONITOR_URL— see "Several monitors" below. - The resident runner's
stoat.runner.*message names (below), which every runner already sends and expects. docs/monitor-design.mdkeeps its filename. Issues, handoffs and the CLAUDE.md seams all link to it by this name, and it documents the protocol at least as much as it ever documented the viewer.
Here "monitor" means the thing a run publishes to. Renaming any of it would be a breaking change for consumers that have nothing to do with where the viewer lives.
NOTE
Sections below describe how the receiving side folds these events — RunProjection, the Pinia runs-store, GET /api/runs/{id}, run-status, the CTRF export. Some of that history was written while the receiver lived in Stoat and still names Stoat issues. It is kept because the fold is half of the contract: a publisher that does not know how its events are read cannot tell an additive change from a breaking one.
Transport: HTTP, fire-and-forget, never slows a run
Publishers (the run bracket in every projected test host, the supervisor, its worker processes) POST batches of events to /api/ingest. HTTP over raw TCP because the emitting client must be dependency-free (HttpClient + STJ only — no Wolverine in Bobcat), and events arrive at tens/second, not thousands. The invariant that outranks all others: a test run is never slowed or failed by the monitor. Probe GET /api/ping once at startup with a tight timeout → publisher goes no-op for the run if nothing answers; bounded queue, drop on backpressure — except the run bracket's two ends, run_started and run_finished, which go through IMonitorEventSink.PostTerminal and are queued past the cap, in order (bobcat#767: a dropped run_finished left a run "running" forever on every board); drops are counted per target and reported once, as run_finished.droppedEvents and a supervisor log line; discovery via BOBCAT_MONITOR_URL (default http://localhost:5525).
Several monitors: BOBCAT_MONITOR_URL is a list
BOBCAT_MONITOR_URL takes more than one origin, comma- or semicolon-separated, and a run publishes everything to every one of them (bobcat#530):
export BOBCAT_MONITOR_URL="http://localhost:5525,http://localhost:5050"
dotnet testTwo consumers now want the same events and only one of them can hold 5525: the run console keeps that port -- bobcat ui, once #524 lands it in this tool -- and Stoat listens on its own so it can show a gate's live progress while the suite runs: run_progress, lane_started/lane_finished, test_stalled, worker_faulted and the step progress inside a scenario. Forwarding from one console to the other was the alternative and was decided against: it would make Stoat's live view depend on the run console running.
What the list means in practice:
- Each target is probed on its own, concurrently, so N absent consoles cost one probe timeout rather than N. A target that does not answer is simply left out; nobody answering is the same "no publisher at all" a single absent console has always been, and
BOBCAT_MONITOR=0still suppresses every probe. - The invariant holds per target, structurally.
MonitorTargetsholds oneMonitorPublisherper URL, and a publisher already owns its HTTP client, its bounded channel, its drop-on-backpressure policy and its give-up counter — so an unreachable, slow, wedged or hostile target cannot cost any other target one event. - The run bracket and the Event Model
PUTgo to every target. The model-name rule (below) is asked of each console separately, because it is a question about what that console currently serves: one console serving another model gets the half withheld, with a notice naming it, while the others still receive it. - Duplicates are dropped —
http://localhost:5525andhttp://localhost:5525/are one target, and a doubled target would double every event on the console reading it. - Workers inherit the variable through
WorkerLaunchContext.Environment, so a supervised run's per-scenario streams fan out exactly as its bracket does. - Taking commands is the other direction and stays singular. A resident runner reads its commands from exactly one console — the first entry of the list, or
bobcat resident --url's first entry — while the run it then performs publishes to all of them. Commands come from somewhere in particular; a run may be watched from anywhere.
Event model
src/Bobcat/Monitoring/MonitorEvents.cs — polymorphic MonitorEvent records. The STJ type discriminator and the Wolverine message type name are pinned to the same snake_case string, so ingestion JSON and the SignalR envelope agree by construction. Identity: RunId (minted per run) + scenario uid "{Feature}/{Scenario}" — the spec identity the retry budget, the spec manifest and WorkPlan already share. RunStarted carries the root repository path + branch, the board's grouping key for parallel suites on one box. RunHeartbeat exists so a crashed/orphaned run renders as such instead of "running" forever.
What one run puts on the wire
One specification from the sample corpus, run with BOBCAT_MONITOR_URL pointed at a listener that recorded every request. In order:
GET /api/ping
GET /api/event-model
POST /api/ingest {"events":[ run_started ]}
PUT /api/event-model/Bobcat-Xunit-Samples?root=…&repository=JasperFx%2Fbobcat&branch=…
POST /api/ingest {"events":[ scenario_started ]}
POST /api/ingest {"events":[ step_started ]}
POST /api/ingest {"events":[ step_finished ]}
POST /api/ingest {"events":[ scenario_finished ]}
POST /api/ingest {"events":[ run_finished ]}Every POST body is a batch, {"events":[…]}, and each event names its type. The three that carry the run's identity and the scenario's verdict, as captured:
{
"type": "run_started",
"suite": "Bobcat.Xunit.Samples",
"repository": "/Users/jeremymiller/code/bobcat",
"branch": "docs-review-reference-pages",
"mode": "xunit",
"startedAt": "2026-10-21T17:12:50.629754+00:00",
"totalScenarios": null,
"tag": null,
"session": "e74337bb-c5fb-48af-a688-7b9986b67730",
"command": null,
"runId": "efaf63f0-eb71-4d78-a47b-d33d062d6c8e"
}{
"type": "scenario_started",
"uid": "Gold customers/a gold customer gets a fifth off",
"feature": "Gold customers",
"scenario": "a gold customer gets a fifth off",
"attempt": 1,
"at": "2026-10-21T17:12:50.761323+00:00",
"totalSteps": 2,
"declaredSteps": [
{ "keyword": "Given", "text": "an order of 100 for a gold customer" },
{ "keyword": "Then", "text": "20 comes off" }
],
"plannedSteps": [
{
"keyword": "Then",
"template": "Discounts.Apply(100, Tier.Gold) should be 80",
"grammar": "ShouldBeTestExtensions.ShouldBe",
"declaredStepNumber": 2
}
],
"parentSpecId": null,
"runId": "efaf63f0-eb71-4d78-a47b-d33d062d6c8e"
}{
"type": "scenario_finished",
"uid": "Gold customers/a gold customer gets a fifth off",
"outcome": "CleanPass",
"attempts": 1,
"durationMs": 7,
"errorMessage": null,
"touchedTypes": null,
"at": "2026-10-21T17:12:50.768928+00:00",
"reports": null,
"stepsMs": 4,
"parentSpecId": null,
"logs": null,
"attachments": null,
"suppressedAttachments": null,
"lifecycle": null,
"runId": "efaf63f0-eb71-4d78-a47b-d33d062d6c8e"
}The session is the agent session that ran it, read from CLAUDE_CODE_SESSION_ID as described below; tag and command are null because nothing set them. Every null on scenario_finished is a trailing optional member described in the sections that follow.
RunStarted carries three opaque attribution strings, and Bobcat interprets none of them — Tag from BOBCAT_RUN_TAG, Session from CLAUDE_CODE_SESSION_ID (issue #389), and Command from BOBCAT_RUN_COMMAND (issue #392). They answer different questions and a run routinely has one without the others: the tag says what work the run speaks for, the session says which agent ran it, and the command says which request produced it. All three are stored and echoed verbatim; the meaning belongs entirely to whoever set them.
The session exists because the alternatives for joining a run to an agent are weak. A plan-node tag only covers runs something thought to tag. Matching the run's repository path against a session's working tree is ambiguous exactly when two agents share a checkout — which is the case most worth seeing. The session id is what the rest of that system already keys on, so it joins exactly.
The command exists so a viewer can follow its own button press to the run it produced — the resident runner's command id (issue #390), stamped on the run that satisfies it. It is not a reuse of Tag, because the two are independent and a commanded run routinely wants both: a slice's specs re-run from the console are still attributed to that slice's plan node. Unlike the session it is a BOBCAT_* variable, because here Bobcat is the thing asking — a cold command launches a child test host and the id travels down to it exactly as BOBCAT_RUN_ID does. A warm host's environment is fixed at launch, so there the runner writes each command's id to the file BOBCAT_RUN_COMMAND_FILE names before sending the request, and the file wins over the variable.
MonitorRunInfo.Discover reads all three the same way, and each is null when absent rather than empty — an empty string on the wire reads as "there was one" to anything checking for null. A supervisor's workers inherit the variables for free, being launched with that environment, but only the bracket owner publishes run_started (see HasExternalOwner), so the owner's value is the one that travels. All three are trailing optionals on the record, so a publisher that omits them is an older publisher and not a broken one.
There are two copies of these records and that is a decision, not drift (issue #65). The publisher's copy is the one above, in Bobcat.Monitoring; the receiver's is src/Bobcat.Ui/Contracts/MonitorEvents.cs. They stay unshared deliberately — a publisher must not depend on the console's Wolverine stack, and the wire shape, not an assembly, is the contract. Bobcat.Ui references nothing else in this repository for the same reason. Bobcat.Ui.Tests' ContractRoundTripTests and ProgressContractRoundTripTests are what keep the two copies honest, so a change made to one alone fails; Bobcat.Tests/Monitoring/FakeMonitorHost is how this side is tested against real HTTP without one.
Bobcat-side seams (issue #65 — built)
CompositeObserver+BobcatRunner.AddObserver— observers fan in additively, so the monitor publisher rides alongside the MTPPublishingObserver.WithObserverkeeps replace semantics. An observer throwing never fails the run or starves other observers.MonitorPublishingObserver+MonitorPublisher(src/Bobcat/Monitoring/) — maps observer callbacks (plus the newRunStarted/RunFinishedrun bracket onIExecutionObserver) onto the wire events; fire-and-forget HTTP with a bounded drop-on-backpressure channel; probes/api/pingonce and no-ops when absent.BOBCAT_MONITOR_URLoverrides the target — a list of them since bobcat#530, fanned out byMonitorTargets, one publisher per URL — andBOBCAT_MONITOR=0is the kill switch. An explicitBOBCAT_MONITOR=1(on/true) says a console is expected: the probe waitsExpectedProbeTimeout(5s) instead of 250ms (bobcat#589). Both resident-runner paths set it, because a child that gives up on a slow-but-present console publishes nothing at all. Publishing is opt-in (BobcatRunner.PublishToMonitor) and turned on only by the real entry points —BobcatRunner.Runand the MTP host's execution path (never discovery) — so unit tests driving the runner never probe.BOBCAT_RUN_IDseeds the run identity so a supervisor can group its workers' streams without supervisor changes.Supervised-run grouping (built 2026-07-31, once supervisor work reopened): the supervisor is the run's monitor-facing OWNER.
Supervisor.PublishToMonitor(opt-in, same policy and probe as the runner's) posts the run bracket itself viaSupervisorRunPublisher— RunStarted with modesupervisedand the true post-filter test total (which no single worker knows), heartbeats, and a RunFinished whose counts includeIndeterminate(never folded into Failed — same split as exit 2 vs 1). Every worker launch — discovery included — inheritsBOBCAT_RUN_ID+BOBCAT_RUN_OWNERviaWorkerLaunchContext.Environment, the LOWEST layer of the env stack (factory shared env andEnvironmentForboth override it).BOBCAT_RUN_OWNERis deliberately a second variable: a worker seeing it suppresses its own bracket (else the first worker to finish would mark the shared run finished with partial counts), whileBOBCAT_RUN_IDalone still just pins identity for a standalone run that keeps its bracket. A cancelled/crashed supervisor posts no RunFinished — heartbeats stop and orphan detection tells the truth.Supervisor.RunTagoverridesBOBCAT_RUN_TAGfor one supervisor (bobcat#768). The variable is read once per process, so several supervisors in one process — a gate running every test project of a repository — would all publish under the same tag. WithRunTagset, the run's sink is wrapped in aTagRewritingSink(a public decorator over anyIMonitorEventSink, rewritingRunStarted.Tagand nothing else) and every worker is launched withBOBCAT_RUN_TAGset to it beside the grouping pair, so the bracket and the workers' own records name the same unit. Null keeps the environment's tag exactly as before.
ISupervisorObserver(built 2026-08-02, issue #84) — the supervisor's live narration:AttemptRecorded(every attempt, passes included, with the policy verdict that followed it),RetryScheduled,LaneStarted/LaneFinished,ResourceRecycled,WorkerFaulted. Every member is a default no-op so a consumer implements only what it wants, and an observer that throws is logged and stepped over — a dashboard must not be able to fail a test run.SupervisorRunPublisheris one, registered automatically whenSupervisor.PublishToMonitoris on.- Retry topology is on the wire: a supervised retry now posts
RetryScheduledwith the disposition and reason, announced after the budget and the resolve step have had their say — a retry that was requested and refused never reaches a watcher as though it were about to happen. - The attempt number is the load-bearing part. A worker counts from one:
MonitorPublishingObserver's tracking belongs to aBobcatRunner, and the MTP host builds a fresh runner per run request, so a retry in a brand-new process and a retry in a reused one both announce attempt 1. The supervisor holds the only true count, soRetryScheduled.NextAttemptpins the number the nextScenarioStartedfolds as — inRunProjectionand in the Pinia store identically. Taken as a floor, never an assignment: an attempt number never goes backwards, because hydration routinely replays a start for an attempt already watched.ScenarioFinished.Attemptsgets the same floor. Before this, a supervised retry overwrote its own previous attempt and CTRF'sretryAttempts[]worked for in-process retries only. - Lane topology, recycles and worker faults are on the wire (built 2026-08-21, the rest of #84):
LaneStarted(lane + the uids it was handed),LaneFinished(outcomes reported,Crashed),ResourceRecycled, andWorkerFaulted(lane or null for a one-test process, the report's sentence, exit code and last standard error as separate fields), each stamped with the supervisor's clock.SupervisorRunPublisherposts them from the observer callbacks;ISupervisorObservergained a structuredWorkerFaulted(WorkerFault)whose default forwards to the originalWorkerFaulted(string), so an observer written against either keeps working. A lane starts again for a same-process retry (back to the lane the test ran in, carrying only the retried uids) — the store counts that as a second pass of the same lane; isolated and recycled retries are one-test processes and never announce a lane, so a foreign-framework worker's lane events are the only live signal it has. Folded in the Pinia runs-store aslanes(lane order, with "running now" = the lane's uids joined to live scenario state),recyclesandfaultson the run; rendered byLaneStripon the card andSupervisorTopologyon the detail. Replay-safe by the supervisor's timestamps: a lane start no newer than the pass we are on, a finish older than that pass, or a recycle/fault already seen is the archive being re-announced over live state, not a new fact. - Folded server-side too (built 2026-08-21, the last piece of #84):
RunProjectioncarriesLanes/Recycles/WorkerFaults(+RunningIn(lane), the lane's uids joined to live scenario state) under exactly the store's rules —SupervisorTopologyProjectionTestsis a case-for-case port ofruns-store-topology.test.ts, so the two folds cannot drift silently, and it includes the same replay-over-live-state no-op. Read by MCPrun_status(lanes,recycles,workerFaults, always present — empty arrays for an in-process run, so an agent never has to guess whether the field is missing), byGET /api/runs/{id}(RunDetail.Lanes/Recycles/WorkerFaults, additive init properties), and by the CTRF export in the results-levelextra(lanes,recycles,workerFaults, omitted for an in-process run so that export is byte-identical to before; CTRF has no vocabulary for worker processes and the schema would reject an invented top-level field). One consequence for the scenario fold: a supervised retry's first attempt reported its own terminal outcome, so a genuinely new attempt'sScenarioStartednow clearsOutcome— the retried scenario reads as running again, which is what a lane's "running now" andrun_statusneed. Only a new attempt clears it (attempt numbers are a floor), so a replayed start never un-finishes one. A crashed lane's scenario that never reported an outcome keeps reading as running — the fold infers nothing for it; the supervisor'sRunFinishedis what counts it Indeterminate. - The observability cluster is on the wire too (built 2026-08-24, issues #145/#146/#148/#149): three additive events posted by
SupervisorRunPublisherfrom the cluster's observer callbacks.worker_started(purpose, lane or null, pid) — a discovery worker is deliberately never announced, because it launches before the run bracket opens andrun_startedstays the stream's first event; its pid folds onto the lane, so lane→pid correlation needs no/procguessing.test_stalled(uid, display name, in-flight ms, lane, pid) — once per attempt, the name a capped CI job's log cannot produce.run_progress(elapsed, done/total, in-flight count, the longest-running test, and peak worker RSS when memory sampling is on — null otherwise, unmeasured is never zero; since bobcat#767 also the verdict splitpassed/failed/retriedandworkersBusy, all optional) — distinct fromrun_heartbeatwhich stays a bare liveness ping; for a foreign-framework worker this is the run's only live progress. Until bobcat#767 it was posted only on the supervisor's opt-inHeartbeatInterval; now the publisher posts it at a bounded cadence on its own — the first change at once, then at most once a second as the counts change with one trailing post at the end of the interval so the newest state lands, immediately on any change of the failed count, and on every heartbeat beat (the one path that learns peak RSS, carried on every later post).completedis tests with a verdict and no retry pending, so a retried test is one test and the figure never exceedstotal; every post measureselapsedMsfromrun_startedon one clock, which is what makes the latest-wins fold below safe. Folded on both sides under the same rules (stallsreplay-guarded by uid+timestamp;progresslatest-wins, ordered by the supervisor's elapsed clock so a replayed older heartbeat never rolls it back; a replacement worker's own start moves the lane's pid) — mirrored case-for-case betweenSupervisorTopologyProjectionTestsandruns-store-topology.test.tslike the rest of the topology. Read back byGET /api/runs/{id}(RunDetail.Stalls/Progress,LaneResult.ProcessId, all additive) and MCPrun_status(stallsalways present,progressnullable,processIdper lane); rendered bySupervisorTopology(progress line, pid column, stalled list). MtpWorkerClient.handleNotificationreceives live per-testtesting/testUpdates/testsupdates; since #99 it relays them (see item 5) instead of reading only the outcome. A supervised run already gets step-level visibility because each worker IS an MTP host running Bobcat's recorder, and its own publisher streams steps directly to the monitor.
- Retry topology is on the wire: a supervised retry now posts
Step-level progress for a scenario in flight (built 2026-08-21, issue #99). Four additive pieces, engine to viewer:
Step n of N with elapsed.
IExecutionObserver.ScenarioStarted(feature, scenario, totalSteps)is a new default member the runner calls (the plan is built before the scenario is announced, so the count is a fact); the two-argument form is what it forwards to, so existing observers are untouched. On the wireScenarioStarted.TotalSteps,StepStarted.StepNumber/TotalSteps/ScenarioElapsedMs,StepFinished.ScenarioElapsedMs— all optional trailing members, null from an older publisher. "Expected" per step is deliberately not here: it needs the cross-run duration ledger (#44 layer 2 / #56 layer 3).Row progress for
[TableGrammar]. The generated envelope callsctx.ReportProgress(StepUpdate.ForRow(k, M))before each row;StepUpdategainedRow/TotalRows. Row ticks carry no message on purpose, so the Spectre console (which prints every message) stays quiet while renderers with a live counter move.One wire event,
step_progress, for both row ticks and the[WaitFor]poll loop's interim message (#32/#34'sStepProgressfinally has a wire form):StepId,Message,Row,TotalRows,ElapsedMssince the step started. Coalesced by the publisher —MonitorPublishingObserverposts at most one per 100 ms per step, always the first update and always the last row — because 200 rows in a few milliseconds would otherwise be 200 events into a channel that drops on backpressure, crowding out theStepFinishedthat matters more. Consumers upsert per step; only the latest matters, and a finished step ignores late (hydration-replayed) progress.step_progress.Cells— the cells so far (built 2026-09-30, issue #387, Stoat's#62part 1). A slow specification fills in cell by cell while it runs, the way Storyteller's did, instead of showing nothing untilstep_finished. One optional trailing member carrying the sameStepCellrecordStepFinished.Cellsuses, so there is no second cell shape. Three semantics a consumer relies on:- The whole set, not a delta. A receiver upserts per step, latest wins, so a coalesced or dropped update costs nothing — the next one restates everything. A delta would turn the coalescing that protects the channel into a correctness problem.
nullmeans "this update says nothing about cells", never "there are none". The receiver keeps the last set it had, which is what lets a[TableGrammar]row tick — rows and no cells — interleave with cell-bearing updates without blanking them.step_finished.Cellsstays the authority. Interim cells are replaced by the final set, so an interim update the coalescer threw away can never leave a wrong final picture.
Both lanes feed it and both had a gap. The engine lane already had the cells on the seam —
StepUpdate.Cells, whichWaitForRunnerfills — and dropped them at the wire, so a poll loop could say a step was still running but never what it had established. The projected lane published nothing until the step ended, so a step making twentySpecAssert.Checkcalls over thirty seconds showed nothing;ScenarioRecorder.RecordCellnow posts the open step's cells, coalesced on the same 100 ms interval per step, and a table a projected step is handed posts its grid once as the step opens. Silent outside a scenario, deliberately:SpecAssertis called from plenty of helpers that are not specifications.The tap.
IWorkerClient.OnTestUpdate(handler)(default no-op) andISupervisorObserver.TestUpdated(WorkerLaunchContext, WorkerTestUpdate)— every node change a worker streams, in-progress included, stamped with the lane and purpose it came from. Discovery is not tapped ("discovered" is not progress). Supervisor-side only for now; see Not built yet for why it has no wire event.Viewer:
ScenarioProgress.vueon the run detail — step n/N bar, current step text, row k/M bar, waiting-for message with elapsed. Store fieldsScenarioState.totalSteps,StepState.stepNumber/scenarioElapsedMs/progress.
Run evidence: touched types on
scenario_finished(built 2026-08-24, issue #107). Slice↔spec binding is by identity plus run evidence, never hand-typed — the runtime half of the #106 descriptor pairing.ScenarioFinishedgained two optional trailing members:TouchedTypes(a list ofTouchedType(Name, FullName, AssemblyName)— deliberately JasperFxTypeDescriptor's three fields, mirrored not referenced, because the contract files stay dependency-free copies;FullNameis the join key against a design-timeSpecificationDescriptor.ResolvedTypes,Uidthe identity both sides key on) andAt(the finish stamp a consumer ages evidence by). Evidence is observed, never asserted:IStepContext.RecordTouchedType(Type)(default no-op) accumulates ontoExecutionResults.TouchedTypesin first-touch order, deduplicated, andBobcat.CritterStack's typed steps record at the point a type actually crossed the scenario — the aggregate arranged, the command dispatched (a validation rejection still received it), the events the stream actually gained, the messages the tracked session actually sent, the read model actually loaded — never what aThenmerely names, so a sad path records its rejected command and no event type. Nothing recorded travels as null, not an empty list (absence of evidence, not evidence of nothing), and the folds assign rather than append so hydration replay cannot double the ledger. Read back per scenario byGET /api/runs/{id}(ScenarioResult.TouchedTypes/FinishedAt, additive) and folded into the Pinia store (ScenarioState.touchedTypes/finishedAt); CTRF/JUnit exports are untouched — they project explicit shapes and CTRF's schema has no vocabulary for this.Foreign per-test progress:
test_started/test_finished(built 2026-09-01, issue #195). A supervised run of a non-Bobcat suite registered on the dashboard with the right total and then never moved —scenariosFinishedstayed 0 for the whole run, observed live at 0/1627 for five minutes, which is nearly indistinguishable from a wedged run. The bracket was right; the gap was that per-scenario events come from each worker's ownMonitorPublishingObserver, and a plain xUnit worker has none. The supervisor already had the facts (ISupervisorObserver.TestUpdated, item 5's tap) and simply was not forwarding them, so this is forwarding, not new machinery.- A separate pair, not
scenario_started/scenario_finished. Those carry spec identity —{Feature}/{Scenario}, the string a design-timeSpecificationDescriptorjoins on — and feeding them an xUnit method uid would widen that meaning for every consumer of the join.TestStarted/TestFinishedcarry the worker's test id and say so; for a Bobcat worker the two strings coincide anyway. Nothing about spec semantics is implied: this is a progress bar, not #110's projection of foreign specs into the Bobcat model. - The worker's own stream always wins. The supervisor forwards for every worker, Bobcat ones included, because it cannot know which of them publishes without a marker only new workers would carry.
ScenarioProjection.WorkerPublished(and the store'sScenarioState.workerPublished) is set by anyscenario_*/step_startedfor a uid, and both new handlers stand down for it. The guard is a property of the scenario, so it holds in either arrival order — a forwarded verdict that lands first is overwritten by the worker's own, one that lands second is ignored — and one test is one card either way. Two extra events per test against a batching, backpressure-dropping publisher was the cheaper side of that trade. - The framework's word travels verbatim.
Stateis Passed / Failed / Error / Skipped / Timeout / Cancelled, never re-labelled by the publisher: two enums meaning the same thing is how a vocabulary drifts.ForeignTestOutcome.From(and its Pinia mirror) does the mapping in one documented place per side — Skipped counts as a clean pass because the supervisor's ownWorkerOutcome.Succeededdoes, so the progress bar and the terminalrun_finishedcounts cannot disagree about the same test; an unrecognised state is a failure rather than a drop, since not counting a finished test stalls the whole bar. The raw word survives onScenarioResult.Statefor anything that wants the distinction. - Indeterminate never reaches the wire. Silence is not a verdict, and a padded outcome is not a live one — a test a crashed worker never answered for publishes no
test_finishedat all, so a crashed run cannot read as a complete one. DurationMsis measured between the two updates on the supervisor's own clock, and is null when it never saw the start — unmeasured is never zero.Laneis null for a one-test isolated or recycled process, the same rule asworker_faulted. Discovery is never tapped.- Free consequence: a supervised xUnit run now has per-test rows in
GET /api/runs/{id}and therefore a CTRF/JUnit eject, without a Bobcat reference anywhere in the suite. specId: the identity beside the uid (added 2026-10-12, issue #515). The fold above — a worker's own scenario stream winning for any uid it has touched — needs the two streams to name the same test, and in the projected lane they never did:test_*.uidis the MTP node uid (a hash) whilescenario_*.uidis{Feature}/{Scenario}. So the fold could not dedupe, and Stoat matched the pair by method name, which is ambiguous across classes and overloads. Both events now carry the spec identity as a trailing optionalspecId, taken from the suite's ownSpecManifestthrough the supervisor's discovery join (SpecUidJoin, the same lookup the resident runner runs a named specification with).- Beside the uid, never instead of it.
uidkeeps its documented meaning — the platform's own id — so no existing consumer changes, and the run-evidence join stays on the scenario events. - Null means nobody could name it, and that is the honest answer for a plain xUnit test, a suite that writes no manifest, a front-end whose discovery places nothing, and an identity more than one test claims (bobcat#489: the manifest keeps one entry, so stamping it would name one of two tests that both are it). A receiving fold therefore keys on
specIdwhen present and falls back to whatever it did before. - Receiving side: fold
test_*onto a scenario whenspecId == {Feature}/{Scenario}, and drop the method-name heuristic. The additive field needs no wire version bump — a reader that predates it deserializes the event exactly as before.
- Beside the uid, never instead of it.
- A separate pair, not
A repeated step is its own row (issue #322, built 2026-09-16)
StepId is a step template id — the step method's name — and was being used as a within-scenario identity on both sides of the wire. It is not one, and the two step shapes the grammar most recently encouraged both repeat: Given {event} occurred once per arranged event (#259) and And no events for {aggregate} "…" once per re-pointed stream (#311, #320). So the viewer under-reported exactly the arrangements the grammar recommends.
Two failures, one cause:
- The projection left later occurrences running.
StepFinishedresolved its step withFirstOrDefault(s => s.StepId == e.StepId), so the first occurrence absorbed every finish. On a real 37-scenario suite: 33 of 182 steps stuck atrunning, across 21 scenarios, every one of them aCleanPass. Nothing contradicted anything a reader was looking at, because a scenario's own outcome is computed elsewhere — which is why it went unnoticed. - The store collapsed them.
handleStepStartedupserted bystepId, so repeats became one row carrying the last occurrence's text and the first one's duration, welded together with nothing saying so.
Resolved without a protocol change, because the wire already carried enough:
- Starts key on
stepNumber, which the publisher increments per scenario. That is occurrence-correct and stable across a replay — and hydration idempotency was the reason the old code keyed onstepIdat all, so it had to survive. - Finishes and progress pair with the first occurrence still running.
StepFinishedcarries nostepNumber, but steps are appended in order and finish in order, so "first still running" is exact. Both the projection and the store use that same rule, and both keep a fallback so a late or replayed event lands somewhere rather than vanishing.
The alternative was adding an ordinal to StepFinished and StepProgress. Not needed, and a wire change is a compatibility question where this is not.
Kept on the publisher's side of the record because stepNumber is what makes it work: it is published from here, one per scenario, and any consumer that keys step identity on stepId instead will collapse the same rows again.
A cell says exactly one thing (issue #396, 2026-10-02)
StepCell gained a fourth content field, Value — the plain text of a cell that was not judged: a decision table's input column, an echoed value. It is null whenever Expected, Actual or Note says something, so a reader is never choosing between two fields that mean the same thing.
Before it, such a cell reached a consumer as {"name":"x","status":"ok","expected":null,"actual":null} and a grid rendered the column empty, because the text lived only in CellResult.DisplayText and nothing on the wire carried it. Both lanes were affected — the projected lane's table literal, and the Gherkin lane's [Table] and [TableGrammar] rows — because both build input cells the same way and there were two copies of the cell projection, each dropping it. There is one now, Bobcat.Monitoring.StepCells.From.
Why a new field and not Actual. "Nothing was judged here" is a fact the report already depends on: a cell with neither an expected nor an actual is how JsonRenderer decides an input column earns no Status column at all (issue #384, and the reason an arrange table has no Status column). Writing an input value into Actual makes every input cell look judged — it broke that very test when tried. So the wire gained the field the JSON report has had all along (value) rather than the two of them disagreeing about what Actual means.
A consumer has to read it. This is the additive-but-invisible case the two-copies decision lives with: a console that only reads expected/actual keeps rendering input columns empty, with nothing to tell it a field appeared. Trailing optional, so an older publisher is not a broken one.
A cell says what it compared (issue #384)
expected 'x', got 'y' is a claim about equality. A cell produced by a non-equality assertion that rendered it was stating something false, so StepCell carries a fifth field, Comparison, as one word from a closed set:
equals · notEquals · greaterThan · greaterThanOrEqual · lessThan · lessThanOrEqual
contains · startsWith · endsWith · approximately · isNull · isNotNull · isEmpty · isNotEmptyNull means equality, which is what every table, set and property cell makes and what every publisher meant before the field existed — so a consumer that ignores it is exactly as correct as it was.
It is not a fifth content field, and that distinction matters when folding these. Expected/Actual, Note and Value are four ways of saying what is in the cell, and a cell fills exactly one. The comparison says how the first pair relates, so it travels beside whichever one was filled rather than competing with it.
Being closed is the point. A producer cannot describe a comparison Bobcat has no member for, so an assertion outside the set yields no cell at all — the step renders as a plain line with its verdict and duration. A free string would let a producer invent a comparison no renderer can render and nothing can check.
A scenario reports an account of itself (issue #408, built 2026-10-06)
ScenarioFinished gained a trailing, optional Reports — a list of ScenarioReportInfo, each a titled grid describing what the scenario's system actually did, as opposed to whether it matched. Bobcat's answer to Storyteller's custom logging, in the Cell model rather than in HTML.
ScenarioReportInfo(Title, Columns, Cells, ShortTitle = null, SuppressedRows = 0)CellsareStepCell. There is one cell shape on this wire and a consumer already knows how to read it, including the rule that a cell says exactly one thing (#396): a report's ordinary informational cell arrives withvalueset andexpected/actualnull, which is also how a reader tells it from a cell that was judged. A report may carry a judged cell among unjudged ones — the dead-lettered envelope among a dozen that were fine — so a grid can have exactly one red row without a second mechanism.Columnsfor the same reason a step's grid needs it: column order is the one thing cells cannot carry themselves, so a reader reassembles the grid fromStepCell.RowIndexagainst this.SuppressedRowsis on the wire rather than folded into the title, because a truncation a consumer cannot see is a report that misleads. Bobcat caps a report at 200 rows. Zero means nothing was dropped.- Absent by default for a passing scenario. The producer declares
OnFailureorAlways, and only--verbose(orBOBCAT_VERBOSE_REPORTS) lifts the latch — on every surface at once, so the wire never disagrees with the console about what a run reported. Null, not an empty list, when there is nothing: "reported nothing" and "a publisher too old to know about reports" mean the same thing to a consumer and should look the same.
The receiving-side fold
Kept here deliberately, like every other fold in this document: a publisher that does not know how its events are read cannot tell an additive change from a breaking one.
- A report is per scenario, not per step, so it belongs wherever a consumer renders a scenario's verdict and detail — beneath the steps, which is where
CommandLineRendererputs it and where Storyteller put its custom logging. - Fold by
Title, which is a report type's stable heading. One scenario cannot send two reports with the same title: Bobcat enforces one instance per report type per scenario, andAttachReportreplaces rather than appends for exactly that reason. - A consumer that ignores
Reportsentirely is exactly as correct as it was before the field existed. Nothing else onscenario_finishedchanged.
The board is a summary, and the console pushes what it decides (bobcat#784, built 2026-10-26)
Nothing on the ingest wire changed. What changed is how bobcat ui reads it back and what it tells a browser, which is kept here because the folds are half the contract.
- The board hydrates from
GET /api/runsalone, one request however many runs the console holds, and loads a run's NDJSON (/export?format=ndjson) only for a run that is live or whose page is open; a reconnect refreshes the summary and replays only the archives already loaded. It used to replay every run's archive on every load and every reconnect — 824 runs on a shared box was 824 sequential fetches and 71 MB before the counts stopped climbing.RunSummarygainedlastEventAt(additive) for it, and the client's run state now carriestag,sessionandcommand, whichrun_startedalways sent and the store dropped. - Live orphan detection.
RunProjection.LastEventAtis the CONSOLE's clock at a live event's arrival (never the publisher's stamp — another box keeps another clock) and the event's own stamp on archive replay (MonitorEventTime), so a rehydrated run reads as last heard from when its publisher last spoke.MonitorRunRegistry.SweepOrphansorphans a live run silent pastOrphanAfter—BOBCAT_MONITOR_ORPHAN_SECONDS/Monitor:OrphanSeconds, 30 s by default, three of the ten-second heartbeats both publishers send; zero turns it off — on a sweepOrphanSweepServiceruns a third of the interval apart. An orphaned run is ejectable and evicted like a finished one, and its next event un-orphans it, exactly as after a restart. - The console's own notices (
src/Bobcat.Ui/Contracts/ConsoleNotices.cs) are pushed to the browser over the same hub, inside thebatched_web_socket_payloadframe after the events, the accumulator keeping only the latest of each type per flush:run_orphaned(runId,lastEventAt),runners_changed(the whole listGET /api/runnersserves) andbuild_status_changed(the whole listGET /api/build-statusserves). They are notMonitorEvents — no publisher sends them, nothing ingests or archives them — so they carry no[JsonDerivedType],relayToStore.tsswitches on them by hand, andTypeScriptContractsmirrors them as interfaces without routing them. A batched item'sdatais therefore declaredobject, written throughBatchedDataConverterso an event keeps its innertypediscriminator. The runner registry raisesChangedfrom every mutation (registration, a stream opening or closing, a status, a command queued, sent or answered), and the ingest endpoint publishes the list again when arun_startednames a command, since that link is the run registry's to know. With these, the 5-second polls of the runners and the build status are gone, and a runner's page follows pushes. ScenarioFinished.Reportsis folded by the live store (ScenarioState.reports, whole, never appended) andSpecificationViewrenders them beneath the steps with the sameReportGridthe HTML results page uses — one renderer for both, as the fold above asked.
A scenario says what its steps cost (issue #466, built 2026-10-11)
ScenarioFinished gained a trailing, optional StepsMs: what the scenario's top-level steps cost, added up. DurationMs was already the whole bracket, so the two together say how much of a scenario no step owns -- test-body code between helpers, an assertion in the test's own library, a wait that isn't a step.
- Projected lane only. The Gherkin runner leaves it null; it reports that time through its lifecycle points instead, and those do not travel on this wire.
- Null means not measured, never zero. A consumer that sees null renders no step figure at all, and one that ignores
StepsMsis exactly as correct as it was before. Nothing else onscenario_finishedchanged. - No lifecycle points, on purpose. In the projected lane fixture setup belongs to the test runner and runs outside the bracket, so there is nothing for Bobcat to report. See marker steps.
What a scenario says before it runs, and what it leaves behind
Four more trailing, optional members, each null from an older publisher and each ignorable by a consumer that predates it. They are in the captured scenario_started and scenario_finished above.
ScenarioStarted.DeclaredSteps(issue #304): what the scenario says it does, in order, before a line of it has run -- a marker-comment test's narrative, a compile-time fact. Sent here rather than asstep_startedevents because publishing it as steps would be claiming it ran. Null from a scenario that declares nothing.ScenarioStarted.PlannedSteps: every step the scenario is going to take, in source order, with its template unresolved (no argument has been evaluated yet) and the grammar method it binds to. It is whatbobcat previewshows, and what lets a watcher render the steps an aborted scenario never reached instead of having them vanish.DeclaredStepNumberis the 1-based index of the declared step it sits under.ScenarioFinished.Logs(bobcat#547): whatSpecLog.Writerecorded, oneStepLogInfoper step that logged (byStepId) and one with a nullStepIdfor lines written between steps, each with aSuppressedcount past the per-step cap. Onscenario_finishedrather thanstep_finishedbecause logs are latched like reports -- kept for a failed scenario, or a verbose run -- and the verdict is only known at the close. Null when nothing is visible.ScenarioFinished.AttachmentsandSuppressedAttachments(issue #571): a reference to each attachment the scenario kept -- name, media type, size, the file's path beside the results file when there is one, the step it hangs on, and why the bytes were omitted when they were. Never the bytes. Latched like reports.SuppressedAttachmentscounts those past the per-scenario cap, so "…and N more" can be said rather than truncating silently.
And one on the bracket: RunFinished.Skipped (bobcat#655), tests the framework skipped, counted in neither Passed nor Failed. Null from the in-process bracket, which never sees a skipped test open a scenario; the supervisor, which does see the skip, sends the count.
A data row names the specification it belongs to (issue #569, built 2026-10-16)
ScenarioStarted and ScenarioFinished gained a trailing, optional ParentSpecId. One row of a data-driven specification (a theory row, a [TestCase]) is its own scenario, with its own Uid -- Discounts/Discount is applied (amount: 100, tier: Gold), or … [gold customer] for a labelled row -- and ParentSpecId is the method's {Feature}/{Scenario}, the one the Event Model lists.
- Fold rows by
ParentSpecId, never by parsingUid. A feature title may contain anything, and the row part is formatted from values; the parent is the only exact key. - Null for a scenario that is not a row, and from an older publisher. A consumer that ignores the field sees each row as its own scenario, which is now what it is. Nothing else changed.
test_started/test_finished.SpecId(#515) carries the method's identity for a row: the manifest lists a row under its method's location, and one location cannot name one row.
An Acceptance spec has its own verdict (bobcat#545, built 2026-10-16)
ScenarioFinished.Outcome gained a value, AcceptanceFailing, and ScenarioFinished a trailing, optional Lifecycle ("Acceptance", or null for the default, Regression). RunFinished gained a trailing, optional AcceptanceFailing count.
- A failing Acceptance spec outside strict mode is
AcceptanceFailing, neverFailed, with itsErrorMessageand every step and cell as usual. It is counted in neitherPassednorFailedand never makesExitCodenon-zero -- the test platform reported it skipped. - A passing one is
CleanPasswithLifecycle: "Acceptance": ready to promote to Regression. - Under
BOBCAT_LIFECYCLE=strictit isFailed, still withLifecycle: "Acceptance". - This is a contract change for every receiver. A consumer that only knows
CleanPass/PassOnRetry/Failed/Abortedwill read the new value as whatever its fallback is --bobcat ui(this repository) shows it as its own status; Stoat needs the matching change.
A run sends its spec half of the Event Model (issue #294; projected since #488)
A run that has a console to talk to also sends that console the spec assembly's half of the Event Model -- the generated BobcatEventModelSource, which carries the slices the specs declare and the {Feature}/{Scenario} identities bound to them. The host half comes from Wolverine's event-model export; the spec half has no other producer, because the reference points the wrong way for the host to see it. Without it a console merges a model where every slice reads "no specification bound".
The wire is two existing routes and nothing new. GET /api/event-model reads the name of the model the console serves now, and PUT /api/event-model/{source} sends the half under a source named for the spec assembly, sanitized for a file name (Bobcat.Xunit.Samples goes as Bobcat-Xunit-Samples). A second send under the same source replaces the first one.
Each send says where the half came from (#532). Both Event Model PUTs -- this one and bobcat import-event-model --url -- carry three optional query parameters: root, the absolute path of the producer's git checkout (or its working directory outside one); repository, the owner/name of the checkout's origin remote; and branch, when HEAD is on one. Stoat uses them to call a half from a different repository or checkout than the newest one stale (stoat#92), which is how halves left over from an older build of a model stop merging into the current canvas. They are read from files only, as the run's own repository and branch are, and a field Bobcat cannot read is left off rather than guessed -- a detached HEAD sends no branch, a remote with no owner/name no repository. A console that predates them ignores them; bobcat ui keeps them with each source's push time since bobcat#799, for the canvas header and its model switcher. The run reads the working directory's checkout, the same one run_started reports; the import reads the checkout of its --out directory, where the stubs it pushes the model for were written.
A half naming a different model is withheld, never sent. The console merges only the sources naming its current model, so sending BankAccountES.Tests to a console serving BankAccountES would not join the host half, it would hide it. The run prints why on standard error, since the test platform owns standard output, and names both fixes, because a mismatch has two causes (bobcat#621): the halves are one application spelled differently -- [assembly: EventModelName("BankAccountES")] on the spec assembly -- or the console answering is another application's, which is routine on a shared machine -- point BOBCAT_MONITOR_URL at your own console or set BOBCAT_MONITOR=0, which also stops the run's events going to it. When the two model names share no word (CritterCrush against CqrsMinimalApi) the second fix is named first; never rename a model after an unrelated application to silence the notice.
A console that lists its models takes more (bobcat#799). bobcat ui keeps every model reachable by name and lists them on GET /api/event-models, so on such a console a half naming another model hides nothing. There the half is sent when the console already holds a model of its name, or when the two names share no word -- another application's model on a shared console, which before #799 kept a spec run's half off the canvas whenever an older model of another repository had been pushed last. It is still withheld, with the notice above, when its name shares a word with the current model's and the console holds no model of that name: that is the misspelt half [assembly: EventModelName] is for. A console that does not list its models (a 404, or any answer without a models array) gets the rule above unchanged.
When it goes. The Gherkin runner sent it from the same place it attached to the console, once per run, and a resident runner ran one run per command, cold or warm. That lane is gone (#471), and since #488 the projected run bracket in MarkerStepRun does the same thing at the same moment: when a bracket opens with live monitor targets. In practice:
- a one-shot
dotnet testsends it once, however many specifications run; - a warm xUnit host sends it once per run request, because
Bobcat.Xunit's session hook opens one bracket per request (#402) -- the old lane's per-command behaviour, and it also puts the half back on a console that was restarted between two commands; - TUnit, NUnit and MSTest suites open the bracket on their first scenario. NUnit and MSTest have no session hook, so they send it once per process. A TUnit host closes the bracket when its session's last scenario ends (#470), so a warm TUnit host sends it once per run request too. Neither needed a platform reference: opening is core code, reached through
BeginScenario; - discovery opens no bracket and sends nothing, and neither does a run with no console or with
BOBCAT_MONITOR=0.
Where the half comes from: the entry assembly, which is the spec assembly in every Microsoft.Testing.Platform host because the test project is the executable, plus the assembly of the scenario that opened the bracket, for a host where it is not. An assembly whose specs declare no slices has no generated source and sends nothing.
It lives under the same rule as the rest of this page: one GET and one PUT, held to a two-second ceiling, only after the console already answered the ping, and nothing in it can fail a run. ProjectedEventModelPublishingTests (Bobcat.Tests) pins the policy, and ProjectedEventModelPublishingEndToEndTests (Bobcat.Supervisor.Tests) runs Bobcat.Xunit.Samples against a capturing console to prove it with a real generated half.
The resident runner wire (issue #390, built 2026-10-02)
A second wire on the same origin, and the only one that runs in the other direction. Everything so far is a run telling a monitor what happened; this is a suite asking a monitor for work.
The runner is a client. It connects out to one origin -- --url's first entry, else BOBCAT_MONITOR_URL's first, else 5525 -- and asks; a monitor can only answer a runner that asked, and can never make one do anything. Nothing listens on a port. Every message is a CloudEvent in structured mode (application/cloudevents+json, specversion 1.0, GUID ids), written and read by hand on this side for the same reason the monitor events are: no package, the wire shape is the contract, and the shapes are pinned by RunnerWireContractTests.
type | Direction | Transport | data |
|---|---|---|---|
bobcat.runner.registered | runner → monitor | POST /api/runners/events | { runnerId, repository, branch, suite, lane, modes[], specs[] } |
bobcat.runner.command.acknowledged | runner → monitor | POST /api/runners/events | { runnerId, commandId, accepted, reason? } |
stoat.runner.command.run | monitor → runner | SSE on GET /api/runners/{runnerId}/commands | { commandId, specs[], mode } |
stoat.runner.command.restart | monitor → runner | same stream | { commandId } |
On the stream, the whole CloudEvent goes in data, with its type as the SSE event name and its id as the SSE id — which is what Last-Event-ID resumes from. Correlation lives in data (commandId), never in the event id alone: the receiving side's mapper replaces an id it cannot read as a GUID with a fresh one, so an id is for de-duplication and resumption and nothing else. keepalive events say nothing and are skipped.
That data rule is worth stating twice, because getting it wrong is silent: a bare payload deserializes into a CloudEvent whose every attribute is its default, so a runner reports "ignored a command of type ''" and the correct code looks broken. The first version of this repository's own test helper framed the payload.
source is bobcat/runner/{runnerId} from a runner and stoat from the monitor.
A command names identities and nothing else — no arguments, paths or flags. Everything it can ask for is something the runner already said it has, which is what makes the open channel safe: the worst a hostile monitor can do is ask for a test run. The identities are {Feature}/{Scenario}, the same string scenario_finished.Uid carries, and the runner translates them to its own lane's filter (issue #391) so a monitor never sends a framework-specific one.
Modes. cold is always offered and is what an absent mode means; warm (issue #393) is offered only by a lane that can keep a host booted, and a command naming a mode the runner did not register is refused, never downgraded. A runner that loses warm — a warm run left the host damaged — withdraws it and re-registers, so a monitor stops offering a button that will now be refused; registered is idempotent precisely so that is safe mid-session. cold is unaffected, because a cold command starts over from whatever poisoned the warm host.
A warm command is still its own run. run_started … run_finished per command, its own RunId, its own Command — so a viewer cannot tell a warm run from a cold one except by its speed. A warm session is not a run; a run is what a person asked for and what carries a verdict.
run_finished does not mean the runner is free, and only the acknowledgement does. The run bracket closes inside the run, a hair before the runner's in-flight slot clears, so a command sent the instant run_finished arrives can still come back refused as busy. That is the contract working — one command at a time, the monitor owns the queue — not a defect, and a client that wants a second run sends again rather than assuming the first answer was yes. On a fast machine the gap is invisible, which is exactly why it is written down: Bobcat's own end-to-end test failed on CI three tags in a row, with the product behaving as specified, before it was.
The run that follows is an ordinary run on the existing ingest stream, carrying the command on run_started.Command (issue #392); its mode is the test host's own (xunit, tunit). A viewer therefore follows its own button press to the run it produced with no new event type.
A refusal carries a machine word beside the sentence (issue #400). refusal on the acknowledgement is one of busy / unknown-spec / unsupported-mode / empty, trailing and optional so an older console ignores it. The split it encodes is that busy means not now and the other three mean not this: busy is the ordinary answer to a command sent the instant run_finished arrives, and the right response is to send again. With only the prose to go on, a console has to match on the sentence — so a rewording here would quietly turn every busy into a hard rejection a person reads as a failed button.
The launch contract a parent sees (issue #397). BOBCAT_RUNNER_ID, when set, is the runner's id — a runner lives under a watch and is relaunched on every source change, so a minted id per start meant a new runner per rebuild, a picker full of dead runners, and commands waiting on an id that never came back. Re-registering is idempotent, which is what makes a stable id safe. And the exit code is how a parent tells "relaunch me" from "I'm done": 75 (EX_TEMPFAIL) after a restart, 0 on an orderly stop. 0 could not carry both, because 0 is also what a non-resident host returns after running its whole suite — a parent relaunching on 0 would run such a suite in a loop forever.
A commanded run reports no Session (issue #401). MonitorRunInfo.Session's getter returns null whenever Command is set, so a with { Command = … } anywhere honours it. A resident runner started from an agent's terminal holds that agent's CLAUDE_CODE_SESSION_ID for its whole life, and without the rule every run it made for a button press was attributed to the session that launched the runner. Tag is unaffected: it answers a different question.
A second lane, out of process (issue #399). A projected suite's entry point belongs to its test framework, so its runner lives outside the suite and runs a command by launching the suite's own test host, narrowed by #391's filter translation. It ships as bobcat resident <host> because the console at the other end references nothing else in this repository and so cannot be handed a class to host. Cold commands each launch a fresh child; bobcat resident also registers warm, which keeps one host open and sends each command into it as a run request. A cold child's environment is pruned on purpose: BOBCAT_RUN_ID would collapse every command into one run card, BOBCAT_RUN_OWNER would stop the child publishing a bracket at all, BOBCAT_LIST_SPECS would turn the run into a listing, and BOBCAT_RESIDENT would have the child register itself instead of running; BOBCAT_MONITOR=1 is forced on and MonitorUrl travels down as BOBCAT_MONITOR_URL -- whole, list and all, so a commanded run reaches every console watching (bobcat#530) while the runner still reads its commands from the one origin it registered with.
The never-slow-never-fail invariant covers this wire too. No monitor → an idle process that keeps asking on a capped backoff. A dropped stream → reconnect with Last-Event-ID. Anything unparseable → ignored, not fatal. The one message that can end the process is restart, which means exactly that.
Not built yet
Step result cells are no longer on this list. StepFinished.Cells and StepProgress.Cells carry them in both lanes, and StepCell.Value (issue #396) completed the shape — so a table step's failure travels as a marked-up grid rather than the sentence it used to be flattened into.
- Elapsed-vs-expected per step. Step progress (Bobcat-side seams item 5) carries elapsed; "expected" needs a duration history across runs, which is the same committed ledger #44 layer 2 and #56 layer 3 want. That ledger exists now (
TestLedger, see ledger-design.md); nothing reads a per-step expectation out of it yet. - Supervisor-side test updates beyond progress.
ISupervisorObserver.TestUpdated(item 5's tap) is supervisor-side only; item 7 forwards the part of it that a progress bar needs and no more.