Thread groups in the Threads app
Maintainer, 2026-10-04: "these views are trash or point nowhere. please make dedicated groups in
Threads app to show their threads." The control plane's threads used to be reachable only through
pages built for something else: the babysitter's status page, the raw Hosting/Triage/_Thread
folder, the supervisor's queue grid and the triage status ledger. They are now listed in the
ordinary Threads app, the AI package's app at /AI/AiThreads, which every home reaches through
the AI tile.
What a viewer sees
The app is a side menu beside a page.
- The side menu has one entry per group. Mine comes first, the declared groups follow by
name, and Other comes last. The number after an entry is how many of its threads are
running now. Each entry is a link:
/AI/AiThreads/{group}, for example/AI/AiThreads/reviews. Assistants and providers (/AI/AiThreads/setup) is the old page with harnesses and model providers. - A system group's page (every group except Mine) is a live runtime table of its threads. See The runtime table below. The page shows at most 60 threads and the heading says how many there are.
- Mine is the viewer's own chats, newest first. Each row shows the readable name, linked to the normal thread view, the state (running, waiting, done or failed) and the last activity in the viewer's time zone. Its page also has the New thread link and the full personal inbox, so close, reopen and undo still work there.
State is derived, not stored. A thread is running while a round executes or input waits for
dispatch (a running delegated sub-thread counts for its root). It is done once marked done. It
is failed once the thread supervisor gave up on it and filed it into triage
(supervisorFeedbackPath). Every other thread is waiting.
The runtime table
Maintainer, 2026-10-04: "we must see runtime agents bug fixing etc". Each system group (Bug fixes, PR work, Babysitter, Reviews, Triage, Platform builds, and any group a starter declares) shows its threads as a data grid, which updates live.
| Column | Where it comes from |
|---|---|
| Thread | The speaking name. A row-scoped button opens that row's thread (/{path}). |
| Status | Executing, ⚠ Stale, ⚠ Parked, Queued, Idle or Done. Stale, Parked and Queued are the verdict of ThreadSupervisor.Classify, the same function the supervisor's sweep uses. A healthy verdict reads Executing while a round is in flight, and Idle or Done otherwise. |
| Agent, Model | The composer's agent. The model of the newest usage record, else the composer's model. |
| Rung | The work item's workRung, when one serves the thread. |
| Round started, Elapsed | The current round's executionStartedAt in the viewer's time zone, and its age. Both are empty at rest. |
| Rounds | The inputs submitted so far (userMessageIds). |
| Last activity | The node's last write, in the viewer's time zone. |
| Tokens, Cost | The sum of the thread's TokenUsage satellites ({thread}/_Usage/{model}), priced on read at the built-in model rates. Cost is empty when no price is known. |
| Serves | The work item's owner/repo#n, else the node the thread is about. |
| Phase, Re-drives, Work cost, Last failure | Bug work only: the work item's bugPhase, bugRedrives, workCost and workLastFailure. |
Order. Executing rows come first. Stale and parked rows come next, flagged with ⚠. All other rows
follow. Within each block the newest activity comes first. A thread that has said Executing for
days with no write, such as Hosting/Triage/_Thread/bug-systemorph-meshweaver-5986 (Executing
since 2026-10-01 21:42), is therefore listed right below the live rounds and flagged as stale.
Header line. {n} executing · {n} queued · {n} parked · {n} stale · today {cost}. Today's cost
sums the usage records that changed today in the viewer's time zone. Each record holds its
thread's cumulative cost for one model, so the figure is the cost of the threads that were active
today, not only today's spend.
How it is built. The page is a template (Doc/GUI/DataBinding, "Templates first, data later").
ThreadGroupLayoutAreas.RuntimeTable declares the header and a DataGridControl fed through
BindGrid. RuntimeFeed is the feed and builds no control. It combines four inputs:
- the viewer's threads;
- their
TokenUsagesatellites; - the
Hosting/TriageItemwork items of kindissue; - a 30-second clock, so elapsed times and the stale verdict move.
Each query runs under the viewer's own identity on their home hub. The usage and work-item feeds
start empty, so the table never waits on them. ThreadRuntime holds the pure projections and the
pinned tests.
The seam to Hosting. The AI engine does not reference the Hosting package. It reads the work
items by node type name (Hosting/TriageItem). It reads their content in whatever shape it arrives:
a JSON element, a JSON object or a typed object serialized to JSON. It reads only the camelCase
fields named above. On a mesh without Hosting the query answers nothing and those columns stay empty.
What the viewer cannot see. The dispatch pool lives in the process that owns the thread. The
table therefore passes the persisted queuedAt marker to Classify in place of the pool's own
answer. The bounds are the supervisor's defaults: parked after 120 s, stale after 900 s or the
thread's own heartbeat bound if larger. They are not the operator's configured values, which live
on an admin node the viewer normally cannot read.
Pinned by ThreadRuntimeTest, which covers:
- every status, including the stale-Executing case;
- the order;
- the round start in both DST directions and across the date rollover;
- the summary and today's cost in the viewer's time zone;
- the shape-tolerant work-item read;
- the usage pricing;
- the open-thread path guard.
AiThreadsApplicationRenderTest.ASystemGroupShowsALiveRuntimeTable renders the Bug fixes page on a
real home hub. Each test was checked to fail against the code before this change or against a
mutant.
Where a thread's group comes from
The starter declares it.
Thread.Group, set once when the thread is created, fromThreadPreparation.GroupthroughStartPreparedThread. The control plane's starters declare theirs throughThreadNamesinHosting/Deployment/Source, the same preparation that carries the speaking name (MeshWeaver.Plugins#2763):Starter Group PR review, post-merge review ( PullRequestIntake)Reviews Triage of a red main, feedback, an issue's tracking thread ( TriageIntake)Triage Bug thread, red-main fix worker, review of their work ( TriageIntake,BugFixPool)Bug fixes PR fixer ( PrBabysitter→PrFixer)PR work Heal validation, the standing babysitter thread ( PrBabysitter)Babysitter Platform build follow-up ( PlatformBuildInboxWatcher)Platform builds The app has no list of groups. It shows the groups its readable threads declare, so a new starter that declares a new group gets a new menu entry. Declare one through a preparation:
new ThreadPreparation { Group = "…" }. A preparation with only a group keeps the platform's default name. A control-plane category token on the node'scategory(review,triage,bugfix,pr-workorbabysitter, the shape MeshWeaver.Plugins#2795 stamps) is read as the group it names. A thread stamped that way therefore lists correctly whichever change lands first. Topic categories (Development,Other, …) never match these tokens.Threads from before the field are placed by
ThreadGroups.LegacyGroup. The agent on the thread's composer is checked first (pr-reviewer,triage,bug-triage/bug-scope,pr-fixer,pr-babysitter,platform-update), then the path (Hosting/Triage/_Thread/review-*,…/bug-*,Hosting/Babysitter/*,Hosting/Triage/*). The agent comes first because a red-main fix worker's idci-{repo}-{issue}shares theci-prefix with triage threads. This table only describes old threads. Never extend it for a new starter; declare the group instead.Otherwise the thread is Mine when the viewer created it or it lives in their home. Any other thread is Other.
The field is group on the thread content. It is separate from the node's category, which is the
conversation's topic (Development, …).
Visibility is access, and nothing else
A group is a display. It never grants anything. The grouped view is the ThreadGroups area on the
viewer's own home hub. The app embeds it there, so its query (ThreadQueries.GroupedThreads:
the viewer's own conversations plus the newest 400 threads they can read across partitions) runs,
is cached and is access-checked per person, never on the shared AI package hub. A viewer without
read on a partition gets none of that partition's threads in any group. A global admin gets
nothing extra either, because a global admin has no data access.
On the control instance the control-plane threads live in Hosting/Triage/_Thread and
Hosting/Babysitter/_Thread. A person sees them through a grant on Hosting. On 2026-10-04 the
maintainer had Hosting/_Access/rbuergi_Access (Viewer + Commenter at the partition root, which
covers both thread folders). Hosting/Triage adds only the Anonymous and Public denials. Anyone
else needs the same grant, issued through a governed activity. The Threads app never issues one.
ThreadGroupsAccessTest pins this on a mesh with row-level security and without the blanket
admin grant. A Viewer of the control partition sees its review threads under Reviews. An outsider
and a global admin with no grant there see none, read off a snapshot that does contain their own
chat.
The home tile
The AI package declares entryPoint: AI/AiThreads. Tiles minted before the app moved still opened
Agent/AiAgents. The package now also declares retiredEntryPoints: ["Agent/AiAgents"], so the
Store's tile refresh (AppTileRefresh.Refreshed) repoints exactly that machine-stamped target to
the Threads app. A tile the viewer aimed elsewhere is left alone.
Not done
- Unread. The platform keeps no per-viewer read state for threads, so the app cannot mark one as unread without making that up. That needs a read receipt the thread view writes.
- The operator's bounds. The table classifies with the supervisor's default bounds, not the ones configured on the instance.
- The chat side rail (
/{user}/Chat, the Blazor thread navigation) still lists the viewer's own threads ungrouped. The groups live in the Threads app.