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.

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:

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:

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

  1. The starter declares it. Thread.Group, set once when the thread is created, from ThreadPreparation.Group through StartPreparedThread. The control plane's starters declare theirs through ThreadNames in Hosting/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's category (review, triage, bugfix, pr-work or babysitter, 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.

  2. 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 id ci-{repo}-{issue} shares the ci- prefix with triage threads. This table only describes old threads. Never extend it for a new starter; declare the group instead.

  3. 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