@terreno/mcp 57.10.1 → 57.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/docs/upgrades/57.11.0.md +80 -0
  2. package/dist/docs/versioned/next/explanation/durable-agent-harness.md +17 -4
  3. package/dist/docs/versioned/next/explanation/local-first-data.md +1 -0
  4. package/dist/docs/versioned/next/how-to/README.md +1 -0
  5. package/dist/docs/versioned/next/how-to/call-external-apis.md +1 -1
  6. package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
  7. package/dist/docs/versioned/next/how-to/preview-demo.md +7 -3
  8. package/dist/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
  9. package/dist/docs/versioned/next/implementationPlans/durable-agent-harness.md +1 -1
  10. package/dist/docs/versioned/next/reference/ai-harness.md +105 -12
  11. package/dist/docs/versioned/next/reference/api.md +64 -2
  12. package/dist/docs/versioned/next/reference/environment-variables.md +1 -1
  13. package/dist/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
  14. package/dist/docs/versioned/next/reference/syncdb.md +156 -6
  15. package/dist/docs/versioned/next/reference/ui.md +6 -5
  16. package/dist/docs/versioned/next/tasks/durable-agent-harness.md +4 -2
  17. package/package.json +3 -3
  18. package/src/docs/upgrades/57.11.0.md +80 -0
  19. package/src/docs/versioned/next/explanation/durable-agent-harness.md +17 -4
  20. package/src/docs/versioned/next/explanation/local-first-data.md +1 -0
  21. package/src/docs/versioned/next/how-to/README.md +1 -0
  22. package/src/docs/versioned/next/how-to/call-external-apis.md +1 -1
  23. package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
  24. package/src/docs/versioned/next/how-to/preview-demo.md +7 -3
  25. package/src/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
  26. package/src/docs/versioned/next/implementationPlans/durable-agent-harness.md +1 -1
  27. package/src/docs/versioned/next/reference/ai-harness.md +105 -12
  28. package/src/docs/versioned/next/reference/api.md +64 -2
  29. package/src/docs/versioned/next/reference/environment-variables.md +1 -1
  30. package/src/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
  31. package/src/docs/versioned/next/reference/syncdb.md +156 -6
  32. package/src/docs/versioned/next/reference/ui.md +6 -5
  33. package/src/docs/versioned/next/tasks/durable-agent-harness.md +4 -2
@@ -141,7 +141,7 @@ The `task` view passed to `run`: `{id, name, version, input, state, phase, attem
141
141
  | --- | --- |
142
142
  | `Harness.open({registry, runner?, models?, env?, priceMap?, streaming?, testHooks?})` | Checks requirements, rejects a duplicate `name@version` or agent name, ensures collections and indexes exist. `runner` defaults to `new InProcessRunner()`. See [open options](#open-options). |
143
143
  | `start()` | Throws, claiming nothing, when any non-terminal task uses a `name@version` missing from the registry (see [Versioning](#versioning)). Then starts the runner. Throws when already started. The runner recovers expired tasks once it owns execution. |
144
- | `stop()` | Stops claiming work, waits (without a time limit) for the phase in flight while still renewing the owner lease, then releases it. No-op when not started. |
144
+ | `stop()` | Stops claiming work, waits (without a time limit) for every phase in flight while still renewing the owner lease, then releases it. No-op when not started. |
145
145
  | `createTask(definition, input, {requestId?, userId?})` | Inserts a `pending` task, its `ObsTrace`, and its root span in one transaction. Wakes the runner. The definition must be in the registry. |
146
146
  | `resolveInterrupted(id, {action, reason, result?, userId?})` | Resolve an `interrupted` task. See [resolveInterrupted](#resolveinterrupted). |
147
147
  | `abort(id, {reason, userId?})` | Abort a task and every non-terminal task it owns, bottom-up. See [abort](#abort). |
@@ -166,7 +166,7 @@ some), throws. A soft-deleted task still owns its `requestId`.
166
166
  | `models` | `({provider, modelId}) => LanguageModel` | Resolves an agent's model refs to Vercel AI SDK models. Required when `registry` lists an agent. |
167
167
  | `env` | `ExecutionEnv` | Handed to phases as `rt.env` and to tools as `api.env`. Optional. |
168
168
  | `priceMap` | `Record<modelId, {inputPerMTok, outputPerMTok}>` | Prices LLM spans (`usage.costUsd`). Defaults to the registered `ObservabilityApp`'s `priceMap`, read at call time. |
169
- | `runner` | `HarnessRunner` | Default `new InProcessRunner()`. |
169
+ | `runner` | `HarnessRunner` | Default `new InProcessRunner()`. For many instances, `JobsRunner` (see [JobsRunner](#jobsrunner)). |
170
170
  | `streaming` | `{deltaFlushChars?, deltaFlushInterval?, deltaTtl?}` | How streamed model text becomes `delta` events. Defaults: 200 characters, `{milliseconds: 250}`, `{hours: 1}`. Non-positive values throw. See [Event stream (SSE)](#event-stream-sse). |
171
171
  | `testHooks` | `HarnessTestHooks` | Test-only. See [Testing](#testing). |
172
172
 
@@ -183,7 +183,8 @@ const harness = await Harness.open({
183
183
 
184
184
  | Option | Default | Description |
185
185
  | --- | --- | --- |
186
- | `pollInterval` | `{milliseconds: 250}` | Idle sleep between claim attempts. `createTask` wakes the runner early. |
186
+ | `concurrency` | `8` | Most claimed tasks run at once. Must be a positive integer, or the constructor throws. `1` runs one task at a time. |
187
+ | `pollInterval` | `{milliseconds: 250}` | Idle sleep between claim attempts. `createTask` and a task that frees a slot wake the runner early. |
187
188
  | `leaseDuration` | `{seconds: 30}` | Lifetime of the owner lease and of each task lease without a renewal. Luxon `DurationLike`. |
188
189
  | `heartbeatInterval` | `{seconds: 10}` | How often both leases are renewed. Must be positive and shorter than `leaseDuration`, or the constructor throws. |
189
190
  | `ownerId` | `hostname:pid:uuid` | Id written into every lease this runner holds. |
@@ -197,13 +198,101 @@ Only the owner claims work. It claims the oldest `pending` task whose `name@vers
197
198
  registered, whose `runAt` is empty or past (by Luxon's `DateTime.now()`), and that has no
198
199
  `abortRequested`. It sets the task `running` with a fresh task lease and runs phases one
199
200
  after another until the task stops: terminal, `pending` for a retry, `waiting`, or
200
- aborted. One task runs at a time. An idle owner re-polls every `pollInterval`, so a retry
201
- starts within one poll of its `runAt`.
201
+ aborted. An idle owner re-polls every `pollInterval`, so a retry starts within one poll of
202
+ its `runAt`.
203
+
204
+ Up to `concurrency` tasks run at once, each under its own task lease and heartbeat, so a
205
+ slow phase (a long model call, a phase blocked on I/O) does not delay other tasks. Ordering
206
+ guarantees:
207
+
208
+ - Tasks are **claimed** oldest first (`created`), one claim at a time, while a slot is free.
209
+ - With `concurrency` above 1, claimed tasks run in parallel, so a later task can finish
210
+ first. Use `concurrency: 1` for strict one-at-a-time order.
211
+ - Phases of one task always run in order, and a phase never commits twice: the claim is an
212
+ atomic update, and every commit is fenced on the task lease. (A runner that froze past
213
+ its lease may still be executing phase code while recovery replays it; only its commit
214
+ is rejected.)
215
+ - Turns of one conversation stay serial regardless of `concurrency`; turns of different
216
+ conversations, and sibling child tasks, run in parallel.
217
+ - `stop()` stops claiming and waits for every task in flight.
218
+
219
+ Before Phase 2 the runner ran one task at a time. Upgrading to the default of 8 means
220
+ parallel model calls (watch provider rate limits and cost) and more stream-counter retries
221
+ (see [Implementation notes](#implementation-notes)). Pass `concurrency: 1` to keep the old
222
+ behavior.
223
+
224
+ ### JobsRunner
225
+
226
+ Runs phases as `@terreno/jobs` jobs so any number of instances share the work. Import it
227
+ from its own entry point; `@terreno/jobs` is an optional peer dependency, and importing
228
+ `@terreno/ai` or `@terreno/ai/harness` never loads it. How-to:
229
+ [Run the harness on multiple instances](../how-to/run-harness-on-multiple-instances.md).
230
+
231
+ ```typescript
232
+ import {JobsApp} from "@terreno/jobs";
233
+ import {Harness} from "@terreno/ai/harness";
234
+ import {JobsRunner} from "@terreno/ai/harness/jobsRunner";
235
+
236
+ const jobs = new JobsApp();
237
+ const harness = await Harness.open({registry, runner: new JobsRunner({jobs})});
238
+ app.register(jobs).build(); // the runner enqueues through the registered jobs service
239
+ await harness.start();
240
+ await jobs.startWorker();
241
+ ```
242
+
243
+ | Option | Default | Description |
244
+ | --- | --- | --- |
245
+ | `jobs` | required | The `JobsApp`. The constructor defines `terreno.harness.phase` on it (`HARNESS_PHASE_JOB_NAME`). Register it and build the app before `harness.start()`; otherwise `start()` throws `harness-config-invalid`. |
246
+ | `pollInterval` | `{milliseconds: 500}` | Sleep between dispatch scans. `createTask`, `sendEvent`, and each finished phase wake the dispatcher early. |
247
+ | `leaseDuration` | `{seconds: 30}` | Lifetime of each task lease without a renewal. |
248
+ | `heartbeatInterval` | `{seconds: 10}` | How often a running phase renews its task lease, and how often this instance sweeps expired leases. Must be positive and shorter than `leaseDuration`, or the constructor throws. |
249
+ | `ownerId` | `hostname:pid:uuid` | Id written into every task lease this runner holds. |
250
+ | `dispatchBatchSize` | `100` | Most runnable tasks one scan enqueues. Must be a positive integer, or the constructor throws. |
251
+
252
+ Every instance runs a dispatcher and a jobs worker. There is no owner lease.
253
+
254
+ 1. **Dispatch.** Each scan lists runnable tasks (the same rule `InProcessRunner` claims by)
255
+ and enqueues one `terreno.harness.phase` job per task with payload `{taskId}` and
256
+ idempotency key `taskId:phase:attempt:claims`. `claims` counts how often the task was
257
+ claimed, so each runnable visit of a phase (first run, retry, event wake, replay) gets
258
+ its own job, and repeated scans or instances enqueue it once. When the job with that key
259
+ already ended without claiming the task (for example it died after its retries, or ran
260
+ on an instance mid-deploy that did not register the task), the dispatcher follows or
261
+ extends the chain `<key>:<endedJobId>` until a job is in flight, so the task is not
262
+ stranded.
263
+ 2. **Run one phase.** The job handler claims that task under a fresh fenced task lease,
264
+ runs **one** phase with heartbeats, and hands a task that is still `running` back as
265
+ `pending` (lease cleared) for the next job. It then wakes the dispatcher. A handler
266
+ whose task is no longer runnable (another job claimed it, or it is aborting) does
267
+ nothing and succeeds.
268
+ 3. **Recover.** Each instance sweeps expired task leases once per `heartbeatInterval`, with
269
+ the same replay and interruption rules as `InProcessRunner`. A replayed task becomes
270
+ runnable with a new `claims` value, so it is dispatched again.
271
+
272
+ Load: with N instances, the recovery sweep (expired leases, settled child waits, stranded
273
+ queued conversations) runs N times per `heartbeatInterval`, where `InProcessRunner` runs it
274
+ once, and each scan does one job lookup per runnable task (up to `dispatchBatchSize`). The
275
+ sweeps are fenced, so this costs queries, not correctness. Raise `heartbeatInterval` and
276
+ `pollInterval` for large fleets.
277
+
278
+ The task lease, not the job lock, is the authority. Two jobs for one phase race on the
279
+ atomic claim, and only one runs it. A worker that froze past its lease cannot commit: its
280
+ `rt.commit` throws `HarnessCommitConflictError`. A phase that runs longer than the jobs
281
+ lock TTL is safe for the same reason. A job that runs on an instance whose harness is not
282
+ started (or is stopping) throws `harness-runner-stopped`, so the jobs worker retries it.
283
+
284
+ `stop()` stops dispatching and waits for every phase this instance is running. Stop the
285
+ jobs worker (`jobs.stopWorker()`) after `harness.stop()`.
286
+
287
+ ### Custom runners
202
288
 
203
289
  A custom runner implements `HarnessRunner` (`start(context)`, `stop()`, `wake()`). The
204
290
  `context` provides `acquireOwnerLease(lease)`, `releaseOwnerLease(lease)`,
205
- `recoverExpired()`, `claimNext(lease)`, and `runTask(task, lease)`, where `lease` is
206
- `{owner, duration, heartbeat}` (Luxon `Duration`s).
291
+ `recoverExpired()`, `claimNext(lease)`, `claimTask(taskId, lease)` (claim one task by id
292
+ when it is runnable now, else `null`), `listRunnable(limit)` (oldest runnable tasks as
293
+ `{taskId, phase, attempt, claims}`, unclaimed), and `runTask(task, lease, {maxPhases?})`,
294
+ where `lease` is `{owner, duration, heartbeat}` (Luxon `Duration`s). With `maxPhases`,
295
+ `runTask` hands a task that is still `running` after that many phases back as `pending`.
207
296
 
208
297
  ## Runtime (rt)
209
298
 
@@ -261,7 +350,9 @@ Assumptions:
261
350
  recovery treats them as expired.
262
351
 
263
352
  Recovery runs when a runner becomes owner (on `start()` or on takeover) and on every owner
264
- heartbeat. It scans up to 100 `running` tasks whose lease expired (or that have no lease),
353
+ heartbeat. `JobsRunner` has no owner lease: every instance sweeps once per
354
+ `heartbeatInterval`, and concurrent sweeps are fenced, so each expired task is recovered
355
+ once. It scans up to 100 `running` tasks whose lease expired (or that have no lease),
265
356
  oldest first, for registered `name@version`s only. A stopping owner skips the sweep. A task
266
357
  whose interruption commit keeps failing for a non-conflict reason is logged and retried on
267
358
  the next sweep.
@@ -1503,6 +1594,7 @@ empty objects are kept (`minimize: false`), so an initial state `{}` is stored a
1503
1594
  | `outcome` | `{status, result, error}` | Set on terminal commit. |
1504
1595
  | `attempt` | Number | Failed attempts of the current phase. Default 0. |
1505
1596
  | `step` | Number | Phase commits so far; names the current phase visit for idempotent child creation. Default 0. |
1597
+ | `claims` | Number | Times a runner claimed the task. Numbers each runnable visit; `JobsRunner` puts it in the job idempotency key. Default 0; a missing value reads as 0. |
1506
1598
  | `retry` | `{maxAttempts, backoffMs, maxBackoffMs}` | Copied from the definition. |
1507
1599
  | `ownership` | `{kind: root \| task \| conversation, id}` | Default `root`. `rt.createTask` children are `{kind: "task", id: <parent>}`; agent turns are `{kind: "conversation", id}`, except subagent turns, which are `{kind: "task", id: <caller>}`. |
1508
1600
  | `rootTaskId` | ObjectId | Top of the ownership tree; equals `_id` for root tasks. |
@@ -1686,12 +1778,13 @@ lists every kind:
1686
1778
  | `code` | `status` | `title` | Kinds of failure |
1687
1779
  | --- | --- | --- | --- |
1688
1780
  | `harness-definition-invalid` | 500 | Invalid harness definition | `defineTask` / `defineTool` / `defineAgent` / `defineExtension` / `approvalGate` validation, and `rt.*` misuse inside a phase. Thrown as `HarnessDefinitionError`, which fails the task at once (no retry). |
1689
- | `harness-config-invalid` | 500 | Invalid harness configuration | `Harness.open`, `HarnessApp`, `InProcessRunner`, `streaming`, and registry problems. |
1781
+ | `harness-config-invalid` | 500 | Invalid harness configuration | `Harness.open`, `HarnessApp`, `InProcessRunner` (lease timing, `concurrency`), `JobsRunner`, `streaming`, and registry problems. |
1690
1782
  | `harness-replica-set-required` | 500 | MongoDB replica set required | `Harness.open` on a deployment without transactions. |
1691
- | `harness-invalid-request` | 400 | Invalid harness request | A caller passed a bad argument (`abort`, `sendEvent`, `decideApproval`, `resolveInterrupted`, `submit` / `send`, `createConversation`, memos). |
1783
+ | `harness-invalid-request` | 400 | Invalid harness request | A caller passed a bad argument (`abort`, `sendEvent`, `decideApproval`, `resolveInterrupted`, `submit` / `send`, `createConversation`, memos, a `terreno.harness.phase` payload without `taskId`). |
1692
1784
  | `harness-not-registered` | 404 | Not registered in this harness | A task definition, agent, or extension the registry lacks. |
1693
1785
  | `harness-not-found` | 404 | Harness record not found | A conversation or child task disappeared. |
1694
- | `harness-already-started` | 409 | Already started | `Harness.start` / `InProcessRunner.start` called twice. |
1786
+ | `harness-already-started` | 409 | Already started | `Harness.start` / `InProcessRunner.start` / `JobsRunner.start` called twice. |
1787
+ | `harness-runner-stopped` | 503 | Harness runner is not running | A `terreno.harness.phase` job ran on an instance whose harness is not started; the jobs worker retries it. |
1695
1788
  | `harness-commit-conflict` | 409 | Harness commit lost its checkpoint fence | `HarnessCommitConflictError`. |
1696
1789
  | `harness-approval-not-pending` | 409 | Approval can no longer be decided | `HarnessApprovalConflictError`. |
1697
1790
  | `harness-conversation-busy` | 409 | Conversation is busy | `HarnessConversationBusyError`. |
@@ -1809,7 +1902,7 @@ Low-risk choices made in the first slice:
1809
1902
  | The SSE tail starts at a cluster time read before any subscriber's replay query | No gap between replay and tail; overlap is removed by `seq`. |
1810
1903
  | One shared change stream per `HarnessApp`, not one per connection | A change stream holds a pooled connection while it waits; per-connection streams let a few dozen viewers starve commits and lease renewals. |
1811
1904
  | Queue recovery is a sweep by the execution owner, not part of the turn's terminal commit | Starting a turn is its own transaction; the sweep (fenced, idempotent) covers the crash window between the two. |
1812
- | Writers to one stream conflict on its counter document | The price of commit-ordered `seq`s. `withTransaction` retries the loser. `InProcessRunner` runs one task at a time, so only HTTP submits, approvals, and deltas contend; a parallel runner fanning out many children of one tree will see retries. |
1905
+ | Writers to one stream conflict on its counter document | The price of commit-ordered `seq`s. `withTransaction` retries the loser. `InProcessRunner` (default `concurrency: 8`) and `JobsRunner` both run sibling tasks of one tree in parallel, so their writes to the shared root stream retry alongside HTTP submits, approvals, and deltas. `InProcessRunner({concurrency: 1})` avoids task-on-task contention. |
1813
1906
  | Interruption spans start at `lease.acquiredAt` | Records when the cut-off phase began. |
1814
1907
  | `resolveInterrupted({action: "abort"})` closes the trace as `error` | `ObsTrace.status` is `ok` or `error`; an abort is not a success. |
1815
1908
  | `requestId` replays are scoped to the same `userId` | Stops one caller from reading another user's task through a shared key. |
@@ -41,7 +41,7 @@ REST API framework built on Express and Mongoose. Provides modelRouter (CRUD end
41
41
  - Validation: `configureOpenApiValidator`, `validateRequestBody`, `validateQueryParams`, `createValidator`
42
42
  - Middleware: `openApiEtagMiddleware`, `sentryAppVersionMiddleware`
43
43
  - Extensibility: `TerrenoPlugin` interface
44
- - Notifiers: `sendToSlack`, `formatSlackUserMention`, `lookupSlackUserIdByEmail`, `sendToGoogleChat`, `sendToZoom`
44
+ - Notifiers: `sendToSlack`, `formatSlackUserMention`, `lookupSlackUserIdByEmail`, `createSlackPrivateChannel`, `inviteSlackUsersToChannel`, `postSlackMessage`, `findSlackChannelByName`, `sendToGoogleChat`, `sendToZoom`
45
45
  - HTTP client: `createAuthenticatedClient`, `withApiErrorHandling`, `normalizeApiError`, `markRetryUnsafe`
46
46
 
47
47
  ## Server Setup
@@ -224,6 +224,30 @@ export const todoRouter = modelRouter("/todos", Todo, {
224
224
  Do not add `endpoints: (router) => { router.get(...) }` when an action fits.
225
225
  Action config supports `allowAnonymous: true` as a per-action override; it does not change authentication for CRUD or sibling actions.
226
226
 
227
+ ## modelRouter list queries
228
+
229
+ `GET /{path}` accepts filters on fields listed in `queryFields` (qs syntax, so operators work:
230
+ `?status[$in][0]=open&created[$gte]=2026-01-01`), plus the reserved `limit`, `page`, and `sort`.
231
+ `limit` is clamped to `maxLimit` (default 500); the response echoes the applied `limit` with
232
+ `{data, limit, more, page, total}`.
233
+
234
+ A filter on a field missing from `queryFields` is rejected before query validation runs, so
235
+ it can never be stripped and silently ignored:
236
+
237
+ ```json
238
+ {
239
+ "status": 400,
240
+ "code": "query-param-not-allowed",
241
+ "title": "Query parameter not allowed",
242
+ "detail": "title is not allowed as a query param. Add \"title\" to queryFields on the Todo modelRouter to filter on it. Allowed: completed, created, ownerId.",
243
+ "source": {"parameter": "title"},
244
+ "meta": {"queryParam": "title", "model": "Todo", "allowedQueryFields": ["completed", "created", "ownerId"]}
245
+ }
246
+ ```
247
+
248
+ `@terreno/syncdb` raises this as `QueryFieldNotAllowedError` (see
249
+ [Query windows](syncdb.md#query-windows)).
250
+
227
251
  ## modelRouter array operations
228
252
 
229
253
  When a model has at least one array field, `modelRouter` provides subroutes for adding,
@@ -1581,6 +1605,44 @@ await sendToSlack(`${formatSlackUserMention("U012ABCDEF")} Deployment complete`,
1581
1605
  });
1582
1606
  ``````
1583
1607
 
1608
+ Incoming webhooks are fixed to the channel chosen when the webhook was created.
1609
+ Private channels, invites, and messages to a channel id use the bot token for
1610
+ one Slack workspace (`SLACK_BOT_TOKEN`):
1611
+
1612
+ | Function | Slack method | Bot scopes |
1613
+ | --- | --- | --- |
1614
+ | `createSlackPrivateChannel` | `conversations.create`, `conversations.invite`, `chat.postMessage` | `groups:write`, `groups:write.invites`, `chat:write` |
1615
+ | `inviteSlackUsersToChannel` | `conversations.invite` | `groups:write.invites` for private channels, `channels:write.invites` for public |
1616
+ | `postSlackMessage` | `chat.postMessage` | `chat:write` |
1617
+ | `findSlackChannelByName` | `conversations.list` | `channels:read`, `groups:read` |
1618
+ | `lookupSlackUserIdByEmail` | `users.lookupByEmail` | `users:read.email` |
1619
+
1620
+ The bot can post and invite only in channels it belongs to. Creating a private
1621
+ channel makes the bot a member. An existing private channel must already
1622
+ include the app; a bot cannot join a private channel by itself. Store the
1623
+ returned channel id. Names are unique, mutable, and limited to lowercase
1624
+ letters, numbers, hyphens, and underscores.
1625
+
1626
+ ``````typescript
1627
+ import {createSlackPrivateChannel, inviteSlackUsersToChannel} from "@terreno/api";
1628
+
1629
+ const channel = await createSlackPrivateChannel({
1630
+ name: "case-12345",
1631
+ userIds: ["U012ABCDEF"],
1632
+ initialMessage: "Case opened.",
1633
+ mentionUserIds: ["U012ABCDEF"],
1634
+ });
1635
+
1636
+ await inviteSlackUsersToChannel({
1637
+ channelId: channel.channelId,
1638
+ userIds: ["U098765432"],
1639
+ });
1640
+ ``````
1641
+
1642
+ A taken name throws status 409. `findSlackChannelByName({name})` returns a
1643
+ public channel, or a private channel the bot is already in. It returns
1644
+ `undefined` when that channel is not visible to the bot.
1645
+
1584
1646
  ### Google Chat Notifications
1585
1647
 
1586
1648
  ``````typescript
@@ -1913,7 +1975,7 @@ Complete reference of environment variables used by @terreno/api:
1913
1975
  | Variable | Required | Default | Description |
1914
1976
  |----------|----------|---------|-------------|
1915
1977
  | `SLACK_WEBHOOKS` | No | — | JSON object mapping names to Slack webhook URLs: `{"default":"https://..."}` |
1916
- | `SLACK_BOT_TOKEN` | No | — | Bot token (`xoxb-…`) with `users:read.email` for `lookupSlackUserIdByEmail` |
1978
+ | `SLACK_BOT_TOKEN` | No | — | Bot token (`xoxb-…`) for one workspace. Scopes: `users:read.email`, `groups:write`, `groups:write.invites`, `channels:write.invites`, `chat:write`, `channels:read`, `groups:read` |
1917
1979
  | `GOOGLE_CHAT_WEBHOOKS` | No | — | JSON object mapping names to Google Chat webhook URLs |
1918
1980
  | `ZOOM_CHAT_WEBHOOKS` | No | — | JSON object mapping names to Zoom webhook URLs |
1919
1981
  | `WEBHOOK_SECRET` | No | — | Secret for validating incoming webhook signatures |
@@ -223,7 +223,7 @@ There is **no** `RATE_LIMIT_ENABLED` (or similar) read by `@terreno/api`. Apps t
223
223
  |----------|---------|----------|---------|--------|-------|
224
224
  | `SLACK_WEBHOOKS` | `@terreno/api` | ❌ | — | Yes | server |
225
225
  | `SLACK_WEBHOOK_URL` | scripts | ❌ | — | Yes | server |
226
- | `SLACK_BOT_TOKEN` | `@terreno/api` `lookupSlackUserIdByEmail` | ❌ | — | Yes | server |
226
+ | `SLACK_BOT_TOKEN` | `@terreno/api` Slack Web API (`lookupSlackUserIdByEmail`, private channels, invites, `chat.postMessage`) | ❌ | — | Yes | server |
227
227
  | `GOOGLE_CHAT_WEBHOOKS` | `@terreno/api` | ❌ | — | Yes | server |
228
228
  | `GOOGLE_CHAT_WEBHOOK_URL` | scripts | ❌ | — | Yes | server |
229
229
  | `ZOOM_CHAT_WEBHOOKS` | `@terreno/api` | ❌ | — | Yes | server |
@@ -16,6 +16,17 @@ loops that invoke those stages; they are not stages and must not appear as `stag
16
16
  | `terreno-4-brew` | Roast PASS for every in-scope task + branch/evidence | pushed head + PR + product-CI trigger check + review-bot wait + attached evidence | Taste |
17
17
  | `terreno-5-taste` | PR + current state | one current-head reaction; before push: fetch latest `master` (merge only when needed), re-verify last-run failed tests locally, run root `prepush` when present (otherwise affected-package checks) in a no-context subagent, then watch CI; invoked directly, repeats bounded reactions until terminal | null or fresh Taste |
18
18
 
19
+ Pre-Grow skill (not a stage):
20
+
21
+ | Skill | Input | Primary output | Next |
22
+ | --- | --- | --- | --- |
23
+ | `terreno-prd` | app owner + conversation, prototype, repository | interview, then a published full product PRD (problem, why now, solution, user stories, success metrics and guardrails, constraints, rollout, out of scope); every metric measurable or `TBD` with its decider | Grow, with the PRD as its spec; metrics become acceptance criteria, `TBD`s become grilling questions, settled product decisions are not re-asked |
24
+
25
+ It is explicit-invoke only (`/terreno-prd`, `$terreno-prd`, `/terreno:prd`). It
26
+ interviews with Grow's grilling procedure over a product tree, is sized for one app owner
27
+ and one or two developers, and carries no implementation or testing decisions; those stay
28
+ in Grow.
29
+
19
30
  Outer loops (not stages):
20
31
 
21
32
  | Skill | What it walks | Default |
@@ -10,6 +10,7 @@ Local-first data layer for Terreno frontends. A TinyBase `MergeableStore` on dev
10
10
  - [createSyncDb configuration](#createsyncdb-configuration)
11
11
  - [SyncDb client methods](#syncdb-client-methods)
12
12
  - [React hooks](#react-hooks)
13
+ - [Query windows](#query-windows)
13
14
  - [Codegen](#codegen)
14
15
  - [Conflict API](#conflict-api)
15
16
  - [Sync status API](#sync-status-api)
@@ -25,7 +26,8 @@ Local-first data layer for Terreno frontends. A TinyBase `MergeableStore` on dev
25
26
  - `createSyncDb`, `SyncDb`, `SyncDbConfig`, `MutateArgs`
26
27
  - `betterAuthAdapter`, `bridgeBetterAuthReactClient`, `AuthProvider`
27
28
  - `listConflicts`, `wipeLocalData`, `generateMutationId`
28
- - React (`@terreno/syncdb/react`): `SyncDbProvider`, `useEntity`, `useQuery`, `useEntityIds`, `useMutate`, `useSyncStatus`, `useConflicts`, `useSyncDebugLog`, `createCollectionHooks`
29
+ - Queries: `QueryFieldNotAllowedError`, `ListRequestError`, `WhereFilter`, `SortSpec`, `WindowQuery`, `QueryWindowState`, `QueryWindows`, `compileWhere`, `DEFAULT_WINDOW_PAGE_SIZE`
30
+ - React (`@terreno/syncdb/react`): `SyncDbProvider`, `useEntity`, `useQuery`, `useEntityIds`, `useWindowQuery`, `useMutate`, `useSyncStatus`, `useConflicts`, `useSyncDebugLog`, `createCollectionHooks`
29
31
  - CLI: `terreno-syncdb-codegen` (generates `SYNC_COLLECTIONS` + friendly hooks from OpenAPI)
30
32
  - Testing (`@terreno/syncdb/testing`): `createFakeTransport`
31
33
 
@@ -165,6 +167,7 @@ be passed to `betterAuthAdapter` directly.
165
167
  | `name` | `string` | — (required) | Persisted database name |
166
168
  | `collections` | `string[]` | — (required) | Collection names to sync (local tables + subscriptions) |
167
169
  | `windowCollections` | `string[]` | `[]` | Collections that join `{collection}\|admin`, skip snapshots/reconcile, and hydrate only known REST membership ids |
170
+ | `queryCollections` | `Array<string \| {collection, path?, fullSync?}>` | `[]` | Collections too large to sync whole (or `fullSync: true` to keep snapshot sync and add windows). No snapshot/reconcile paging; rows arrive through [query windows](#query-windows) against the `modelRouter` list endpoint (`path`, default `/{collection}`) plus live deltas on the user's normal streams. Each entry must also be in `collections` and not in `windowCollections`. |
168
171
  | `organizationIdProvider` | `() => string \| undefined` | — | Read at send time: sets `X-Organization-Id` on HTTP sync calls and `organizationId` on socket mutate payloads |
169
172
  | `authProvider` | `AuthProvider` | — (required) | `{getToken, getUserId, onAuthChange, refresh?}` |
170
173
  | `baseUrl` | `string` | — | Server origin; required unless both `transport` and `httpChannel` are injected |
@@ -200,6 +203,7 @@ be passed to `betterAuthAdapter` directly.
200
203
  | `mutate({collection, operation, id?, data?})` | Optimistic local write + durable outbox enqueue + fire-and-forget replay. Returns `{mutationId, id}`. |
201
204
  | `reconcile()` | HTTP snapshot catch-up for every known stream; runs tombstone compaction on success. Also runs automatically on (re)connect, on a rate-limited seq-jump hint, and on the periodic timer; each `sync:subscribed` confirmation additionally pages just the streams it names. |
202
205
  | `hydrateWindow({collection, ids, restRows?})` | Admin window upsert: REST rows (optional) land immediately; every requested id is also fetched from `GET /sync/entities` before this resolves so seq/deleted metadata is canonical for immediate update/delete. A `{collection}|admin` delta that lands while the fetch is in flight wins, so hydration never rewinds a row to an older seq. Unknown ids are ignored. |
206
+ | `queryWindows` | Server query windows for `queryCollections`: `fetchWindow({query, nextPage?})`, `getWindow(query)`, `retain(query)` → release, `refetchRetained()`, `pruneUnretained()`, `subscribe(cb)`, `keyFor(query)`. See [Query windows](#query-windows). |
203
207
  | `forceResync()` | Purge every known stream locally and re-bootstrap from cursor 0 (outbox/conflicts untouched). Returns `{ok, reason?, streams, purged, repaired}`. After discovery and each stream bootstrap it waits for in-flight `start`/`stop`/auth-change work, then abandons with `reason: "superseded"` when that work switched users or generations. |
204
208
  | `replayOutbox()` | Drain queued mutations for the current user now. |
205
209
  | `resolveConflict({mutationId, strategy})` | Apply `"useServer"` or `"keepMine"` to a recorded conflict. |
@@ -271,13 +275,26 @@ Subscribe to a single entity. Re-renders when that row changes. `isPending` is t
271
275
 
272
276
  ```typescript
273
277
  const todos = useQuery<Todo>("todos", {
274
- filter: (t) => !t.completed,
275
- sort: (a, b) => b.created.localeCompare(a.created),
276
- includeDeleted: false,
278
+ where: {completed: false, created: {$gte: since}},
279
+ sort: "-created",
280
+ limit: 20,
277
281
  });
282
+
283
+ // Callbacks still work and combine with `where`:
284
+ const mine = useQuery<Todo>("todos", {filter: (t) => t.ownerId === userId, sort: byCreatedDesc});
278
285
  ```
279
286
 
280
- Returns decoded entity data arrays. Tombstones excluded unless `includeDeleted: true`. Filter and sort run in JS — memoize callbacks when collections are large.
287
+ Returns decoded entity data arrays from the local store. Tombstones excluded unless `includeDeleted: true`.
288
+
289
+ | Option | Type | Description |
290
+ |--------|------|-------------|
291
+ | `where` | `WhereFilter` | Mongo-style filter: equality, dot paths, `$eq` `$ne` `$gt` `$gte` `$lt` `$lte` `$in` `$nin` `$exists`, `$and` / `$or`. An array field matches when any element equals the value. Compared by value, so inline objects are fine. Throws for operators only the server can run (`$search`, `$regex`). |
292
+ | `filter` | `(data) => boolean` | Arbitrary predicate, applied after `where`. |
293
+ | `sort` | `SortSpec \| comparator` | `"-created name"`, `{created: "descending"}`, or `(a, b) => number`. |
294
+ | `limit` | `number` | Keep the first N rows after filter and sort. |
295
+ | `includeDeleted` | `boolean` | Include tombstones. |
296
+
297
+ Filter and sort run in JS — memoize `filter`/comparator callbacks when collections are large.
281
298
 
282
299
  ### `useEntityIds(collection, options?)`
283
300
 
@@ -288,6 +305,38 @@ return ids.map((id) => <TodoRow key={id} id={id} />);
288
305
 
289
306
  Same options as `useQuery`, but returns only ordered ids with **referential stability** — the array identity changes only when membership or order changes, not on field updates. Pair with per-row `useEntity` for large lists.
290
307
 
308
+ ### `useWindowQuery(collection, options?)`
309
+
310
+ ```typescript
311
+ const {data, isLoading, isFetching, hasMore, fetchNextPage, refetch, total, error} =
312
+ useWindowQuery<Message>("messages", {
313
+ where: {threadId, deleted: false},
314
+ sort: "-created",
315
+ pageSize: 30,
316
+ });
317
+ ```
318
+
319
+ Server-filtered list over a `queryCollections` collection. See [Query windows](#query-windows).
320
+
321
+ | Option | Type | Default | Description |
322
+ |--------|------|---------|-------------|
323
+ | `where` | `WhereFilter` | — | Sent as qs list params (`status[$in][0]=open`). Fields must be in the route's `queryFields`. |
324
+ | `sort` | `SortSpec` | route default | Server `sort`; also orders the local view. |
325
+ | `pageSize` | `number` | `50` | Server `limit` per page (capped by the route's `maxLimit`). |
326
+ | `skip` | `boolean` | `false` | Do not retain or fetch (e.g. a required param is missing). |
327
+ | `liveMatch` | `boolean` | `true` | Show cached rows that newly match `where` inside the loaded range. |
328
+
329
+ | Result | Description |
330
+ |--------|-------------|
331
+ | `data` / `ids` | This window's rows in view order. |
332
+ | `isLoading` | No membership yet (first fetch in flight). Cached membership renders with `isLoading: false`. |
333
+ | `isFetching` | Any request for this window is in flight. |
334
+ | `isError` / `error` | Last request failed (cached rows still render). |
335
+ | `errorCode` | Server error code of the last failure; `"query-param-not-allowed"` means a `where` field is missing from `queryFields`. |
336
+ | `hasMore` / `total` | From the list response's `more` / `total`. |
337
+ | `fetchNextPage()` | Append the next server page. |
338
+ | `refetch()` | Reload every loaded page in one request, replacing membership. |
339
+
291
340
  ### `useMutate(collection)`
292
341
 
293
342
  ```typescript
@@ -331,7 +380,7 @@ Returns the `SyncDb` instance from context (escape hatch for imperative calls li
331
380
 
332
381
  ### `createCollectionHooks`
333
382
 
334
- Factory used by `terreno-syncdb-codegen` and by hand-written custom collections. Returns five operation hooks (`useListQuery`, `useReadQuery`, `useCreateMutation`, `useUpdateMutation`, `useDeleteMutation`). Generated SDKs rename them to friendly names (`useTodos`, `useTodo`, `useCreateTodo`, …). Mutation hooks return `[trigger]`; triggers apply locally and return `{mutationId, id}` synchronously. Optional `retries` maps to `maxAttempts` (`false` → 1, a number → that many, omitted → engine default).
383
+ Factory used by `terreno-syncdb-codegen` and by hand-written custom collections. Returns six operation hooks (`useListQuery`, `useWindowQuery`, `useReadQuery`, `useCreateMutation`, `useUpdateMutation`, `useDeleteMutation`). Generated SDKs rename them to friendly names (`useTodos`, `useTodosWindow`, `useTodo`, `useCreateTodo`, …). `useWindowQuery` works only for collections listed in `queryCollections`. Mutation hooks return `[trigger]`; triggers apply locally and return `{mutationId, id}` synchronously. Optional `retries` maps to `maxAttempts` (`false` → 1, a number → that many, omitted → engine default).
335
384
 
336
385
  ```typescript
337
386
  export const {useListQuery: useNotes, useCreateMutation: useCreateNote} =
@@ -340,6 +389,107 @@ export const {useListQuery: useNotes, useCreateMutation: useCreateNote} =
340
389
  });
341
390
  ```
342
391
 
392
+ ## Query windows
393
+
394
+ Use query windows when a collection is too large to sync whole (messages, events, audit
395
+ rows). They match what an RTK list query did — `where`, `sort`, `limit`, `page`, `more`,
396
+ `total` — but keep the data local-first and deduplicated.
397
+
398
+ ```typescript
399
+ export const syncDb = createSyncDb({
400
+ collections: ["todos", "messages"],
401
+ queryCollections: [{collection: "messages", path: "/messages"}],
402
+ // ...
403
+ });
404
+ ```
405
+
406
+ **Membership, not copies.** A window is one list query against the collection's
407
+ `modelRouter` list endpoint. Its result is an ordered id list stored in the reserved
408
+ `_queryWindows` table. Each row lands once in the collection's entity table, keyed by id.
409
+ Two windows that overlap share those rows:
410
+
411
+ - A delta, mutation, or refetch for a shared row writes the store once; every window that
412
+ lists the row re-renders from it.
413
+ - Each window lists only its own members. Window B never shows rows that only window A
414
+ fetched.
415
+
416
+ **Live membership.** When `where` is locally evaluable (see `useQuery`'s `where`), a window
417
+ also:
418
+
419
+ - hides a member whose fields stop matching (an unpinned message leaves the "pinned" window);
420
+ - shows cached rows that newly match (a live delta, or a local create) when they sort inside
421
+ the loaded range. With more pages unloaded, a row that sorts after the last loaded row
422
+ waits for `fetchNextPage()`. Without a `sort` and with more pages, only local creates are
423
+ admitted.
424
+
425
+ A full fetch also records cached rows that matched `where` locally but that the server did
426
+ not return (deleted, changed, or no longer visible while this device was offline). They stay
427
+ out of the view until a newer delta or a local edit changes them, so stale cached data never
428
+ resurrects a row.
429
+
430
+ Set `liveMatch: false` to show server membership only. Operators only the server runs
431
+ (`$search`) also fall back to server membership.
432
+
433
+ **Fetching.** The first `retain` of a window in a session fetches it; persisted membership
434
+ renders meanwhile, so windows work offline. Concurrent fetches of the same window share one
435
+ request; a refetch and a `fetchNextPage()` on one window run one after the other. Retained
436
+ windows refetch on reconnect and after `forceResync()` — query collections
437
+ skip snapshot catch-up, so this is how deltas missed offline are recovered. Windows not on
438
+ screen at that moment refetch the next time they are shown. Window fetches
439
+ started before a `stop()` or user switch are discarded.
440
+
441
+ **Seq.** List rows that carry `_syncSeq` (the `syncPlugin` field) are written with that seq.
442
+ Rows without it (a `responseHandler` that strips it) are re-read from `GET /sync/entities` so
443
+ a later mutation never sends a stale `baseVersion`. Rows with a pending outbox mutation, or
444
+ with a newer seq from a delta, are never overwritten. A list `responseHandler` must return the
445
+ same shape as the sync serializer; otherwise list rows and delta rows differ.
446
+
447
+ **Missing `queryFields`.** Every `where` field must be in the route's `queryFields`. The
448
+ server answers 400 `query-param-not-allowed` naming the field, the model, and the allowed
449
+ list. syncdb turns it into `QueryFieldNotAllowedError` (`field`, `allowedQueryFields`,
450
+ `model`), logs it with `console.error`, sets the window's `errorCode` to
451
+ `"query-param-not-allowed"`, and rejects `fetchWindow` / `refetch` / `fetchNextPage` with it.
452
+ Other list failures throw `ListRequestError` (`status`, `code`, `detail`).
453
+
454
+ ```typescript
455
+ import {QueryFieldNotAllowedError} from "@terreno/syncdb";
456
+
457
+ try {
458
+ await refetch();
459
+ } catch (error) {
460
+ if (error instanceof QueryFieldNotAllowedError) {
461
+ // error.field === "title", error.allowedQueryFields === ["completed", "created", "ownerId"]
462
+ }
463
+ }
464
+ ```
465
+
466
+ **`maxLimit` and pipelining.** `modelRouter` clamps `limit` to the route's `maxLimit` and
467
+ echoes the applied value. Window requests always send `page` (so the server never logs a
468
+ truncated unpaginated list). When a fetch needs more rows than one request returns — a
469
+ `refetch()` after several `fetchNextPage()` calls, or a `pageSize` above `maxLimit` — syncdb
470
+ learns the cap from the echoed `limit` and splits the range into sequential page requests
471
+ of `maxLimit` rows starting at the aligned page that contains the range, trimming the extra
472
+ rows, so offsets stay exact. Each pipelined fetch logs a
473
+ `console.warn` naming the row count, the cap, and the request count. Fix it by loading fewer
474
+ pages, lowering `pageSize`, or raising `maxLimit` on the route.
475
+
476
+ **Fully synced collections.** `queryCollections: [{collection: "todos", fullSync: true}]`
477
+ keeps normal snapshot sync and also enables windows, for server-side filtering, paging, and
478
+ totals over a collection that is already local. See `example-frontend/app/todo-windows.tsx`.
479
+
480
+ **Limits.**
481
+
482
+ - `refetch()` with several pages loaded reloads `pageSize × pages` rows (see below).
483
+ - Page appends dedupe ids. A delete between page loads can shift offsets and skip one row
484
+ until the next refetch.
485
+ - `null` in `where` is rejected (qs would send the string `"null"`); use `{$exists: false}`.
486
+ A top-level `{$in: []}` returns an empty window without a request; an empty array anywhere
487
+ else throws instead of silently widening the query.
488
+ - Rows stay in the entity table after their windows unmount. `start()` prunes persisted
489
+ membership of unretained windows not fetched for 7 days (`pruneStale`);
490
+ `pruneUnretained()` drops all unretained membership. Rows are dropped by leave-purge,
491
+ tombstone compaction, or a wipe.
492
+
343
493
  ## Codegen
344
494
 
345
495
  `terreno-syncdb-codegen` is a bin of `@terreno/syncdb`. It reads OpenAPI, discovers list operations with `x-terreno-sync`, and writes typed hooks plus `SYNC_COLLECTIONS`.
@@ -725,11 +725,12 @@ Master-detail layout. Pass `listViewData` plus `renderListViewItem` for the list
725
725
  screens the detail replaces the list until the user goes back.
726
726
 
727
727
  `desktopChildrenMinWidth` opts into a minimum pixel width for each desktop child when there
728
- are one or two children. `narrowViewportChildLabels` opts into a labeled full-width pager on
729
- the narrow viewport. `narrowBelowWidth` uses that viewport when the window is at or below the
730
- given pixel width; when omitted, the narrow viewport follows `isNarrowViewport()`. These props
731
- are web only; the native `SplitPage` ignores them. See `SplitPageProps` for when each prop
732
- applies and what is ignored.
728
+ are one or two children. `childColumnRounding` sets the border radius on each detail child
729
+ column container (defaults to `md`, 4px); the list column is excluded. `narrowViewportChildLabels`
730
+ opts into a labeled full-width pager on the narrow viewport. `narrowBelowWidth` uses that
731
+ viewport when the window is at or below the given pixel width; when omitted, the narrow
732
+ viewport follows `isNarrowViewport()`. These props are web only; the native `SplitPage`
733
+ ignores them. See `SplitPageProps` for when each prop applies and what is ignored.
733
734
  `IconButton`'s `backgroundOpacity` tints only that button's background.
734
735
 
735
736
  ```typescript
@@ -131,21 +131,23 @@ created in 1.1 and extended by every later task — never deferred.
131
131
 
132
132
  ## Phase 2 — Multi-instance
133
133
 
134
- - [ ] **Task 2.0**: `InProcessRunner` concurrency
134
+ - [x] **Task 2.0**: `InProcessRunner` concurrency
135
135
  - Delivers: `new InProcessRunner({concurrency})` (default 8) runs up to N claimed tasks at once, each with its own lease and heartbeat; a slow phase no longer delays other tasks' wakes; `stop()` waits for all in-flight phases
136
136
  - Files: `ai/src/harness/runners/inProcessRunner.ts`, tests
137
137
  - Blocked by: 1.12
138
138
  - Skills: `terreno-backend-api`
139
139
  - Docs: reference "Runners" (concurrency option, ordering guarantees)
140
140
  - Acceptance: bun test — a phase blocked on a gate does not prevent a second task from completing; never more than N concurrent; no task runs twice concurrently
141
+ - Shipped: fake-context cases in `ai/src/harness/runners/inProcessRunner.test.ts` (default 8, gate, peak, stop waits for all, invalid concurrency) and real-Mongo cases in `ai/src/harness/concurrency.test.ts` (blocked task does not hold up a second; 7 two-phase tasks at concurrency 3 peak at exactly 3 with no task running twice). Roast: PASS (2026-10-04; 76 targeted tests, three mutation checks red)
141
142
 
142
- - [ ] **Task 2.1**: `JobsRunner`
143
+ - [x] **Task 2.1**: `JobsRunner`
143
144
  - Delivers: `@terreno/ai/harness/jobsRunner` with optional peer `@terreno/jobs`; job `terreno.harness.phase` keyed `taskId:phase:attempt`; harness task lease + heartbeat is the authority; importing the root `@terreno/ai` never loads jobs
144
145
  - Files: `ai/src/harness/runners/jobsRunner.ts`, `ai/package.json`, tests
145
146
  - Blocked by: 2.0
146
147
  - Skills: `terreno-backend-api`
147
148
  - Docs: reference "Runners"; how-to "Run on multiple instances"
148
149
  - Acceptance: AC11 — two workers race one phase; exactly one commit; stale owner rejected; knip clean without jobs installed
150
+ - Shipped: `ai/src/harness/runners/jobsRunner.test.ts` (two-worker race, stale-lease commit rejected, re-dispatch of a dead job, root imports never reach `@terreno/jobs`). Drift: the job key is `taskId:phase:attempt:claims` — `claims` (new `HarnessTask` counter) gives a phase revisited by an event wake or crash replay its own job, since jobs idempotency keys are permanent. Built before Task 2.0; it does not depend on `InProcessRunner` concurrency. Roast: PASS (2026-10-04; 17 targeted tests, ai suite 1185/0, knip clean, two mutation checks red)
149
151
 
150
152
  - [ ] **Task 2.2**: Runs admin screen
151
153
  - Delivers: task list/tree with status filters, abort, resolve-interrupted (retry/abort/complete + reason), link to trace