@coreplane/switchboard 1.229.0 → 1.230.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 (29) hide show
  1. package/dist/assets/config/config.example.yaml +10 -0
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +56 -29
  3. package/dist/assets/deploy/cloudflare-resident/worker.ts +39 -20
  4. package/dist/assets/package-lock.json +6 -3
  5. package/dist/assets/package.json +2 -1
  6. package/dist/assets/source.json +3 -3
  7. package/dist/assets/src/core/authz/actor.ts +24 -6
  8. package/dist/assets/src/core/coordinator/contract.ts +19 -0
  9. package/dist/assets/src/core/runEvents.ts +22 -5
  10. package/dist/assets/src/core/runFriction.ts +4 -3
  11. package/dist/assets/src/core/runLedger/sessionLog.ts +13 -0
  12. package/dist/assets/src/core/runRecord.ts +33 -0
  13. package/dist/assets/src/core/trace/attrs.ts +3 -0
  14. package/dist/assets/src/core/trace/streamSpans.ts +4 -1
  15. package/dist/assets/src/execution/residentRebind.ts +27 -4
  16. package/dist/assets/web/dist/.vite/manifest.json +19 -19
  17. package/dist/assets/web/dist/assets/CostsPage-B08bdD-1.js +2 -0
  18. package/dist/assets/web/dist/assets/{ResidentDetailPage-CkVYktwT.js → ResidentDetailPage-DYxDruS0.js} +1 -1
  19. package/dist/assets/web/dist/assets/{ResidentsIndexPage-BLanxf27.js → ResidentsIndexPage-DIBRBmuq.js} +1 -1
  20. package/dist/assets/web/dist/assets/{RunRoutePage-U3nwL8Df.js → RunRoutePage-B4s8hJlW.js} +4 -4
  21. package/dist/assets/web/dist/assets/{RunsIndexPage-DrIVxmpl.js → RunsIndexPage-CaW07KOf.js} +1 -1
  22. package/dist/assets/web/dist/assets/{ScheduledPage-DpqubmIm.js → ScheduledPage-Cr7269s9.js} +1 -1
  23. package/dist/assets/web/dist/assets/{StatusDot-BpD9MRge.js → StatusDot-lCLi16zY.js} +1 -1
  24. package/dist/assets/web/dist/assets/{Tooltip-BYv0WSrA.js → Tooltip-DKMPwNyy.js} +1 -1
  25. package/dist/assets/web/dist/assets/{dist-BZmA5qTt.js → dist-DRkTB2rt.js} +1 -1
  26. package/dist/assets/web/dist/assets/{main-C4GOEklV.js → main-CXKOiCOK.js} +2 -2
  27. package/dist/cli.js +1341 -635
  28. package/package.json +1 -1
  29. package/dist/assets/web/dist/assets/CostsPage-DQg30mHr.js +0 -2
@@ -363,6 +363,16 @@ workspaceDir: ./workspaces
363
363
  # model: anthropic/claude-haiku-4-5
364
364
  # answer: text
365
365
 
366
+ # Linked threads (docs/decisions/0037-a-linked-thread-is-quoted-not-joined.md;
367
+ # docs/reference/specs/routing-and-config.md item 22). Off by default. On, a
368
+ # permalink in a request to a thread in another PUBLIC channel the bot is a
369
+ # member of is quoted onto the request turn as an untrusted block — text only,
370
+ # at most three per request, never a private channel from elsewhere, never a
371
+ # DM or a Slack Connect channel — and the run's record and page show what was
372
+ # quoted. A bot older than 1.228.0 refuses a config that carries this key.
373
+ # references:
374
+ # enabled: true
375
+
366
376
  # Spend reporting: GET /costs (Access-gated). Optional. See docs/reference/specs/costs.md.
367
377
  # costs:
368
378
  # cloudflareAccountId: <32-hex account id>
@@ -51,6 +51,7 @@ import {
51
51
  planSessionTrim,
52
52
  roleOfStoredRow,
53
53
  rowKind,
54
+ SEARCH_MAX_HITS,
54
55
  sessionsToDrop,
55
56
  tailCut,
56
57
  textOfStoredRow,
@@ -148,7 +149,7 @@ const traceSinks = [workerLogSink((line) => console.log(line))];
148
149
  // keeps the 512 KB one.
149
150
  // POST /runs/put {storeKey, record, policy?, policyUpdatedAt?} → {ok, retained, stored, rewritten}
150
151
  // POST /runs/get {storeKey, id} → {record: RunRecord | null} (unknown/expired: null, 200)
151
- // POST /runs/list {storeKey, limit?, before?, beforeId?, sinceMs?, agent?, channel?, threadKey?}
152
+ // POST /runs/list {storeKey, limit?, before?, beforeId?, sinceMs?, agent?, channel?, threadKey?, parentRunId?}
152
153
  // → {items: RunListItem[], nextBefore?: {finishedAt, id}} (cursor = the last row's list key)
153
154
  // POST /runs/events {storeKey, id, afterSeq?, limit?} → {events: (RunEvent & {seq})[] | null, nextAfterSeq?}
154
155
  // (`events: null` when the run is unknown or hidden by retention; `seq` is the registry's stamp)
@@ -225,7 +226,6 @@ const MAX_MATCH_TOKENS = 24;
225
226
  /** A `recall` query's size and the most hits one answers (session-log item 10):
226
227
  * a query is a few words, and the tool's default is five. */
227
228
  const MAX_SEARCH_QUERY_BYTES = 1_024;
228
- const MAX_SEARCH_HITS = 50;
229
229
  /** Request body ceiling, checked against Content-Length before parsing. A full
230
230
  * batch (50 × 4000-char texts + keywords + envelope) fits comfortably. */
231
231
  const MAX_BODY_BYTES = 512 * 1024;
@@ -1152,31 +1152,16 @@ export class RunHistoryDO extends DurableObject<Env> {
1152
1152
  value TEXT NOT NULL
1153
1153
  );
1154
1154
  `);
1155
- // The one column migration this DO has (the run-visibility stamp): a table
1156
- // created before the visibility stamp gains the column with `unknown` for
1157
- // every existing row — so a run written before the stamp is never public.
1158
- // Then the indexes the visibility predicate's leaves walk (`channel_id IN`,
1159
- // `channel_visibility IN`, `user_id =`), each ordered like the page.
1160
- const columns = new Set(
1161
- this.sql
1162
- .exec<{ name: string }>(`PRAGMA table_info(runs)`)
1163
- .toArray()
1164
- .map((c) => c.name),
1165
- );
1166
- if (!columns.has("channel_visibility"))
1167
- this.sql.exec(`ALTER TABLE runs ADD COLUMN channel_visibility TEXT NOT NULL DEFAULT 'unknown'`);
1168
- // The session a run was a range of (session-log item 7), so the sweep can
1169
- // tell which sessions still have a kept run; null for a record without one.
1170
- if (!columns.has("session_key")) this.sql.exec(`ALTER TABLE runs ADD COLUMN session_key TEXT`);
1171
- // What the run cost in tokens (costs.md, cost by user): the record's `usage`
1172
- // as JSON; NULL for a record written before the field existed, until the
1173
- // by-user aggregate fills it in from the run's stored events.
1174
- if (!columns.has("usage_json")) this.sql.exec(`ALTER TABLE runs ADD COLUMN usage_json TEXT`);
1155
+ this.migrateRunsTable();
1156
+ // The indexes the visibility predicate's leaves walk (`channel_id IN`,
1157
+ // `channel_visibility IN`, `user_id =`), each ordered like the page; the
1158
+ // session the sweep asks about; the parent a children listing filters on.
1175
1159
  this.sql.exec(`
1176
1160
  CREATE INDEX IF NOT EXISTS runs_channel_finished ON runs(channel_id, finished_at DESC, run_id DESC);
1177
1161
  CREATE INDEX IF NOT EXISTS runs_visibility_finished ON runs(channel_visibility, finished_at DESC, run_id DESC);
1178
1162
  CREATE INDEX IF NOT EXISTS runs_user_finished ON runs(user_id, finished_at DESC, run_id DESC);
1179
1163
  CREATE INDEX IF NOT EXISTS runs_session ON runs(session_key);
1164
+ CREATE INDEX IF NOT EXISTS runs_parent ON runs(parent_run_id, finished_at DESC, run_id DESC);
1180
1165
  `);
1181
1166
  // The sessions registry (session-log item 7): every session log a run of
1182
1167
  // this store claimed, with its thread — the sweep cannot enumerate the
@@ -1333,6 +1318,37 @@ export class RunHistoryDO extends DurableObject<Env> {
1333
1318
  return r ? rowToLive(r) : undefined;
1334
1319
  }
1335
1320
 
1321
+ /** The `runs` table's column migrations, run at every construction and
1322
+ * idempotent: a table created before a column existed gains it, with the
1323
+ * value a row written back then should read. The run-visibility stamp: a
1324
+ * run written before the stamp is `unknown`, never public. The session a
1325
+ * run was a range of (session-log item 7), so the sweep can tell which
1326
+ * sessions still have a kept run; null for a record without one. What the
1327
+ * run cost in tokens (costs.md, cost by user), NULL until the by-user
1328
+ * aggregate fills it from the run's stored events. The parent a child names
1329
+ * (run-history item 46), the column a children listing filters on: the
1330
+ * record already carries it in `summary_json`, so existing rows are filled
1331
+ * from there once, and every later `put` writes it beside the row. */
1332
+ migrateRunsTable(): void {
1333
+ const columns = new Set(
1334
+ this.sql
1335
+ .exec<{ name: string }>(`PRAGMA table_info(runs)`)
1336
+ .toArray()
1337
+ .map((c) => c.name),
1338
+ );
1339
+ if (!columns.has("channel_visibility"))
1340
+ this.sql.exec(`ALTER TABLE runs ADD COLUMN channel_visibility TEXT NOT NULL DEFAULT 'unknown'`);
1341
+ if (!columns.has("session_key")) this.sql.exec(`ALTER TABLE runs ADD COLUMN session_key TEXT`);
1342
+ if (!columns.has("usage_json")) this.sql.exec(`ALTER TABLE runs ADD COLUMN usage_json TEXT`);
1343
+ if (!columns.has("parent_run_id")) {
1344
+ this.sql.exec(`ALTER TABLE runs ADD COLUMN parent_run_id TEXT`);
1345
+ this.sql.exec(
1346
+ `UPDATE runs SET parent_run_id = json_extract(summary_json, '$.parentRunId')
1347
+ WHERE json_type(summary_json, '$.parentRunId') = 'text'`,
1348
+ );
1349
+ }
1350
+ }
1351
+
1336
1352
  private liveByThread(threadKey: string): LiveRunRow | undefined {
1337
1353
  const r = this.sql.exec<LiveRow>(`SELECT * FROM live_runs WHERE thread_key = ?`, threadKey).toArray()[0];
1338
1354
  return r ? rowToLive(r) : undefined;
@@ -1832,8 +1848,8 @@ export class RunHistoryDO extends DurableObject<Env> {
1832
1848
  );
1833
1849
  this.sql.exec(
1834
1850
  `INSERT INTO runs (run_id, label, agent, model, channel_id, user_id, thread_key, channel_visibility, repo, started_at, finished_at, stored_at, status,
1835
- event_count, stored_event_count, truncated, bytes, diagnosis_json, summary_json, session_key, usage_json)
1836
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1851
+ event_count, stored_event_count, truncated, bytes, diagnosis_json, summary_json, session_key, usage_json, parent_run_id)
1852
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1837
1853
  ON CONFLICT(run_id) DO UPDATE SET
1838
1854
  label = excluded.label, agent = excluded.agent, model = excluded.model, channel_id = excluded.channel_id,
1839
1855
  user_id = excluded.user_id, thread_key = excluded.thread_key, channel_visibility = excluded.channel_visibility,
@@ -1842,7 +1858,8 @@ export class RunHistoryDO extends DurableObject<Env> {
1842
1858
  event_count = excluded.event_count, stored_event_count = excluded.stored_event_count, truncated = excluded.truncated,
1843
1859
  bytes = excluded.bytes, diagnosis_json = excluded.diagnosis_json, summary_json = excluded.summary_json,
1844
1860
  session_key = excluded.session_key,
1845
- usage_json = COALESCE(excluded.usage_json, runs.usage_json)`,
1861
+ usage_json = COALESCE(excluded.usage_json, runs.usage_json),
1862
+ parent_run_id = excluded.parent_run_id`,
1846
1863
  stored.id,
1847
1864
  stored.label ?? null,
1848
1865
  stored.agent ?? null,
@@ -1864,6 +1881,7 @@ export class RunHistoryDO extends DurableObject<Env> {
1864
1881
  JSON.stringify(summary),
1865
1882
  stored.session?.key ?? null,
1866
1883
  stored.usage ? JSON.stringify(stored.usage) : null,
1884
+ stored.parentRunId ?? null,
1867
1885
  );
1868
1886
  // The session's registry row learns its newest finish (session-log item
1869
1887
  // 7); a record that reaches the store without a claim (the plain put
@@ -2199,6 +2217,10 @@ export class RunHistoryDO extends DurableObject<Env> {
2199
2217
  where.push(`thread_key = ?`);
2200
2218
  params.push(q.threadKey);
2201
2219
  }
2220
+ if (q.parentRunId !== undefined) {
2221
+ where.push(`parent_run_id = ?`);
2222
+ params.push(q.parentRunId);
2223
+ }
2202
2224
  if (q.visibleTo !== undefined && q.visibleTo.kind !== "all") where.push(visibilitySql(q.visibleTo, params));
2203
2225
  const select = `SELECT run_id, agent, channel_id, finished_at, bytes, event_count, summary_json FROM runs WHERE ${where.join(" AND ")} ORDER BY finished_at DESC, run_id DESC`;
2204
2226
  // `LIMIT` holds on the over-bound path too: the kept set is the newest
@@ -2441,6 +2463,11 @@ function parseRunList(body: unknown): Validated<{ storeKey: string; query: RunLi
2441
2463
  return invalid(`${field} must be a string of at most ${MAX_KEY_CHARS} characters`);
2442
2464
  query[field] = v;
2443
2465
  }
2466
+ if (b.parentRunId !== undefined) {
2467
+ const id = parseRunId(b.parentRunId);
2468
+ if (!id.ok) return invalid("parentRunId must match ^[A-Za-z0-9_-]{1,64}$");
2469
+ query.parentRunId = id.value;
2470
+ }
2444
2471
  if (b.visibleTo !== undefined) {
2445
2472
  // A malformed filter is a 400, never "all": the bot degrades to live rows
2446
2473
  // rather than the DO widening what an actor may see.
@@ -2453,8 +2480,8 @@ function parseRunList(body: unknown): Validated<{ storeKey: string; query: RunLi
2453
2480
  }
2454
2481
 
2455
2482
  /** Parameters the page query binds before any filter: the cursor pair (3) and the age floor (1),
2456
- * plus `agent`, `channel`, `threadKey`, and the LIMIT at most — the headroom `visibleTo` must fit under. */
2457
- const RUN_LIST_BASE_PARAMETERS = 8;
2483
+ * plus `agent`, `channel`, `threadKey`, `parentRunId`, and the LIMIT at most — the headroom `visibleTo` must fit under. */
2484
+ const RUN_LIST_BASE_PARAMETERS = 9;
2458
2485
 
2459
2486
  /** How many `?` a filter binds (one per id, one per user). */
2460
2487
  function boundParameters(f: RunVisibilityFilter): number {
@@ -3400,8 +3427,8 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
3400
3427
  )
3401
3428
  return json({ error: `query must be a non-empty string of at most ${MAX_SEARCH_QUERY_BYTES} bytes` }, 400);
3402
3429
  const limit = b.limit;
3403
- if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > MAX_SEARCH_HITS)
3404
- return json({ error: `limit must be an integer in 1..${MAX_SEARCH_HITS}` }, 400);
3430
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > SEARCH_MAX_HITS)
3431
+ return json({ error: `limit must be an integer in 1..${SEARCH_MAX_HITS}` }, 400);
3405
3432
  const hits = await stub.search(b.query, limit);
3406
3433
  const gaps =
3407
3434
  hits.length > 1
@@ -1110,7 +1110,9 @@ const NO_REF_HINT_REASON: RefHintReason = { ownPr: null, refByDefault: false };
1110
1110
  type RebindOutcome =
1111
1111
  | { kind: "none"; binding: ThreadBinding }
1112
1112
  | { kind: "refuse"; refused: RebindRefused }
1113
- | { kind: "rebound"; moved: ThreadBinding; rebound: Rebound };
1113
+ /** `note`: the move was the record alone — a dirty tree already on the
1114
+ * branch, no checkout run — in the verdict's words, for the log line. */
1115
+ | { kind: "rebound"; moved: ThreadBinding; rebound: Rebound; note?: string };
1114
1116
 
1115
1117
  /** Result of one /op test/build execution. `ok` is the command's
1116
1118
  * verdict — a failing test run is a RESULT with ok:false, never an error. */
@@ -4654,29 +4656,44 @@ export class ResidentDO extends Sandbox<Env> {
4654
4656
  );
4655
4657
  tree.readable = status.exitCode === 0;
4656
4658
  if (tree.readable) tree.dirty = status.stdout.trim() !== "";
4659
+ if (tree.dirty) {
4660
+ // A dirty tree is refused unless its HEAD is already the branch
4661
+ // (the run made it here and left an edit after pushing): the
4662
+ // one fact that tells the two apart, measured only when it
4663
+ // decides anything.
4664
+ const head = await this.threadRun(
4665
+ current.user,
4666
+ wt,
4667
+ "git rev-parse --abbrev-ref HEAD",
4668
+ DEFAULT_EXEC_TIMEOUT_MS,
4669
+ );
4670
+ if (head.exitCode === 0) tree.head = head.stdout.trim();
4671
+ }
4657
4672
  }
4658
4673
  // Judged on the re-read plan: it carries whether the branch is the
4659
4674
  // thread's own, which the pre-lock plan lacks when it was a recreate.
4660
4675
  const verdict = rebindVerdict(again, tree);
4661
4676
  if (verdict.kind === "recreate") return this.recreateAtOwnBranch(current, again, fetchToken);
4662
4677
  if (verdict.kind !== "rebind") return verdict;
4663
- const startedAt = systemClock();
4664
- const checkout = await this.threadRun(
4665
- current.user,
4666
- wt,
4667
- `git checkout --quiet ${shellQuote(again.to)}`,
4668
- DEFAULT_EXEC_TIMEOUT_MS,
4669
- );
4670
- this.stepTrace.getStore()?.record("rebind-checkout", {
4671
- startedAt,
4672
- endedAt: systemClock(),
4673
- exitCode: checkout.exitCode,
4674
- timedOut: checkout.timedOut,
4675
- });
4676
- if (checkout.exitCode !== 0) {
4677
- const detail =
4678
- checkout.stderr.trim().split("\n")[0]?.slice(0, 200) || `git checkout exited ${checkout.exitCode}`;
4679
- return { kind: "refuse", refused: rebindRefused(again, "checkout-failed", detail) };
4678
+ if (verdict.checkout) {
4679
+ const startedAt = systemClock();
4680
+ const checkout = await this.threadRun(
4681
+ current.user,
4682
+ wt,
4683
+ `git checkout --quiet ${shellQuote(again.to)}`,
4684
+ DEFAULT_EXEC_TIMEOUT_MS,
4685
+ );
4686
+ this.stepTrace.getStore()?.record("rebind-checkout", {
4687
+ startedAt,
4688
+ endedAt: systemClock(),
4689
+ exitCode: checkout.exitCode,
4690
+ timedOut: checkout.timedOut,
4691
+ });
4692
+ if (checkout.exitCode !== 0) {
4693
+ const detail =
4694
+ checkout.stderr.trim().split("\n")[0]?.slice(0, 200) || `git checkout exited ${checkout.exitCode}`;
4695
+ return { kind: "refuse", refused: rebindRefused(again, "checkout-failed", detail) };
4696
+ }
4680
4697
  }
4681
4698
  const rebound: Rebound = {
4682
4699
  from: current.ref,
@@ -4686,7 +4703,9 @@ export class ResidentDO extends Sandbox<Env> {
4686
4703
  };
4687
4704
  const moved: ThreadBinding = { ...current, ref: again.to, rebound };
4688
4705
  await this.ctx.storage.put(key, moved);
4689
- return { kind: "rebound", moved, rebound };
4706
+ return verdict.checkout
4707
+ ? { kind: "rebound", moved, rebound }
4708
+ : { kind: "rebound", moved, rebound, note: verdict.note };
4690
4709
  }, ATTACH_MUTEX_WAIT_MS)
4691
4710
  ).value;
4692
4711
  } catch (err) {
@@ -4705,7 +4724,7 @@ export class ResidentDO extends Sandbox<Env> {
4705
4724
  }
4706
4725
  const { moved, rebound } = outcome;
4707
4726
  console.log(
4708
- `attach ${prior.threadKey}: rebound ${rebound.from} → ${rebound.to} (the thread's own pull request #${rebound.pr})`,
4727
+ `attach ${prior.threadKey}: rebound ${rebound.from} → ${rebound.to} (the thread's own pull request #${rebound.pr})${outcome.note ? ` — ${outcome.note}` : ""}`,
4709
4728
  );
4710
4729
  return { binding: moved, rebound };
4711
4730
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.229.0",
3
+ "version": "1.230.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.229.0",
9
+ "version": "1.230.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -40,6 +40,7 @@
40
40
  "eslint-plugin-vue": "^10.11.0",
41
41
  "globals": "^17.12.0",
42
42
  "prettier": "^3.9.6",
43
+ "semver": "^7.8.5",
43
44
  "tsx": "^4.23.13",
44
45
  "typescript": "^5.9.3",
45
46
  "typescript-eslint": "^8.70.0",
@@ -652,6 +653,8 @@
652
653
  },
653
654
  "deploy/cloudflare-memory/node_modules/vitest": {
654
655
  "version": "4.1.11",
656
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
657
+ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
655
658
  "dev": true,
656
659
  "license": "MIT",
657
660
  "dependencies": {
@@ -20029,7 +20032,7 @@
20029
20032
  },
20030
20033
  "packages/switchboard": {
20031
20034
  "name": "@coreplane/switchboard",
20032
- "version": "1.229.0",
20035
+ "version": "1.230.0",
20033
20036
  "license": "Apache-2.0",
20034
20037
  "dependencies": {
20035
20038
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.229.0",
3
+ "version": "1.230.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -97,6 +97,7 @@
97
97
  "eslint-plugin-vue": "^10.11.0",
98
98
  "globals": "^17.12.0",
99
99
  "prettier": "^3.9.6",
100
+ "semver": "^7.8.5",
100
101
  "tsx": "^4.23.13",
101
102
  "typescript": "^5.9.3",
102
103
  "typescript-eslint": "^8.70.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.229.0",
3
- "commit": "ab0a0726ab1471eb99f55ea986c542195ccad495",
4
- "builtAt": "2026-09-15T22:07:49.544Z"
2
+ "version": "1.230.0",
3
+ "commit": "8fbe49d06a940d9c899fdd7c689f16efdaaef4ed",
4
+ "builtAt": "2026-09-15T23:38:19.975Z"
5
5
  }
@@ -89,12 +89,30 @@ const CHAT_SURFACES: Readonly<Record<string, ActorSurface>> = {
89
89
  * A namespace this module does not know stays a `user` with the id as given —
90
90
  * its grants are whatever config names for that id, never a guess. */
91
91
  export function resolveChatActor(
92
- msg: { userId: string; channelId: string; threadKey: string },
92
+ msg: { userId: string; channelId: string; threadKey: string; postedBy?: string },
93
93
  grantsFor: GrantsLookup,
94
94
  ): Actor {
95
- const colon = msg.userId.indexOf(":");
96
- const surface = colon > 0 ? CHAT_SURFACES[msg.userId.slice(0, colon)] : undefined;
97
- const origin = { channelId: msg.channelId, threadKey: msg.threadKey };
98
- if (surface === undefined) return { kind: "user", id: msg.userId, grants: grantsFor(msg.userId), origin };
99
- return resolveActor({ surface, subjectId: msg.userId.slice(colon + 1), ...origin }, grantsFor);
95
+ const person = resolveNamespacedActor(msg.userId, msg, grantsFor);
96
+ if (msg.postedBy === undefined) return person;
97
+ // A request an app posted for a person (slack-channel.md item 13): the
98
+ // message text named the person, and text is forgeable, so the person's
99
+ // grants alone must never govern. The actor is the app, acting on the
100
+ // person's behalf — `effectiveGrants` is the intersection, so the run holds
101
+ // no more than the app holds (the surface baseline, plus whatever config
102
+ // grants that app id by name) and no more than the person holds. Identity
103
+ // (`userId`, the record, the costs page) is still the person's.
104
+ const app = resolveNamespacedActor(msg.postedBy, msg, grantsFor);
105
+ return { ...app, kind: "agent", onBehalfOf: person };
106
+ }
107
+
108
+ function resolveNamespacedActor(
109
+ userId: string,
110
+ origin: { channelId: string; threadKey: string },
111
+ grantsFor: GrantsLookup,
112
+ ): Actor {
113
+ const colon = userId.indexOf(":");
114
+ const surface = colon > 0 ? CHAT_SURFACES[userId.slice(0, colon)] : undefined;
115
+ const at = { channelId: origin.channelId, threadKey: origin.threadKey };
116
+ if (surface === undefined) return { kind: "user", id: userId, grants: grantsFor(userId), origin: at };
117
+ return resolveActor({ surface, subjectId: userId.slice(colon + 1), ...at }, grantsFor);
100
118
  }
@@ -36,6 +36,25 @@ export function idempotencyKeyFor(parentInstanceId: string, step: string): strin
36
36
  return `${parentInstanceId}:${step}`;
37
37
  }
38
38
 
39
+ /** A unit's id as the plan spells it (`U16`) or `task` — the `unit` field of a unit row. */
40
+ export const UNIT_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
41
+ /** `<instanceId>:<unit>` — the one name a unit has outside its instance: the
42
+ * prefix every child's idempotency key carries before its `/<round>/<kind>`
43
+ * step, so a unit is addressed by the same words its runs are stamped with.
44
+ * An instance id has no colon, so the first colon splits the two halves. */
45
+ export const UNIT_KEY_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_-]{0,99}:[A-Za-z0-9_-]{1,32}$/;
46
+
47
+ export function unitKeyOf(unit: { instanceId: string; unit: string }): string {
48
+ return `${unit.instanceId}:${unit.unit}`;
49
+ }
50
+
51
+ /** The two halves of a unit key, or undefined for anything that is not one. */
52
+ export function parseUnitKey(key: string): { instanceId: string; unit: string } | undefined {
53
+ if (!UNIT_KEY_PATTERN.test(key)) return undefined;
54
+ const at = key.indexOf(":");
55
+ return { instanceId: key.slice(0, at), unit: key.slice(at + 1) };
56
+ }
57
+
39
58
  /** The event a child's terminal record sends its parent: the type carries the
40
59
  * run id, so each `waitForEvent` matches its own child and a duplicate is
41
60
  * buffered harmlessly. An event type is the platform's alphabet — letters,
@@ -76,11 +76,13 @@ export type RunNoteKind =
76
76
  * wait had refused; on pi the container is provisioned before pi starts, so
77
77
  * the note is a record fact from before the loop's deletion. */
78
78
  | "fleet_busy"
79
- /** The sandbox restarted under the run and came back (docs/reference/specs/
80
- * resident-repos.md item 65): the executor waited for the resident's wake
81
- * and re-attached. The native loop settled the interrupted call and went
82
- * on; the pi harness has no such settlement yet (harness-pi.md, the item 19
83
- * gap), so the note is a record fact from before the loop's deletion. */
79
+ /** The container the run's pi ran in was replaced under the live run
80
+ * (docs/reference/specs/harness-pi.md item 16; the resident's roll,
81
+ * resident-repos.md item 65): the harness settled the call in flight with
82
+ * the restart note, the summary names both containers, and the run ends
83
+ * `interrupted` for a restart from its request. On a record from before the
84
+ * native loop's deletion the note says that loop's settlement instead: the
85
+ * executor waited for the wake and the run went on. */
84
86
  | "sandbox_restarted"
85
87
  | "stop_requested"
86
88
  | "stopped"
@@ -114,6 +116,11 @@ export type RunNoteKind =
114
116
  * bounded extra model turn to submit it (docs/reference/specs/pr-description.md
115
117
  * item 5). Published by the dispatcher before that turn. */
116
118
  | "description_turn"
119
+ /** A review run's loop ended on a pull request without `submit_verdict`, and
120
+ * the same run is being given one bounded extra model turn to call it
121
+ * (docs/reference/specs/agent-review.md item 5; verdictTurn.ts). Published by
122
+ * the dispatcher before that turn. */
123
+ | "verdict_turn"
117
124
  /** The run is on a cold per-thread sandbox instead of a warm resident, and
118
125
  * the summary says why — the resident attach failed (its steps so far are
119
126
  * grafted under the attach span), the resident was unreachable or not
@@ -156,6 +163,14 @@ export type RunNoteKind =
156
163
  * know — named, so a pi bump is visible in the first run's record. Published
157
164
  * by the pi bridge. */
158
165
  | "harness_error"
166
+ /** The model provider refused the run's call under its usage policy — the
167
+ * stop reason its wire names for a classifier's refusal, never the words
168
+ * (harness-pi.md item 6): the summary carries the provider's explanation
169
+ * for the run page; the run fails by name, its record says
170
+ * `failure: policy_refusal` (run-history.md item 57), the thread reads one
171
+ * sentence on how to go on, and the session's next seed leaves the refused
172
+ * request out (session-log.md item 9). Published by the pi harness. */
173
+ | "policy_refusal"
159
174
  /** The harness's gate refused a tool call the model asked for (harness-pi.md
160
175
  * item 7): the summary names the tool and the rule; the model read the same
161
176
  * reason as the tool's result. Published by the bot's authorize route. */
@@ -183,12 +198,14 @@ export const RUN_NOTE_KINDS = [
183
198
  "resumed",
184
199
  "seed",
185
200
  "description_turn",
201
+ "verdict_turn",
186
202
  "cold_sandbox",
187
203
  "rebind_refused",
188
204
  "pr_not_opened",
189
205
  "review_not_posted",
190
206
  "compacted",
191
207
  "harness_error",
208
+ "policy_refusal",
192
209
  "tool_refused",
193
210
  "stuck_loop",
194
211
  ] as const satisfies readonly RunNoteKind[];
@@ -564,9 +564,10 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
564
564
  });
565
565
  return;
566
566
  case "sandbox_restarted":
567
- // The container rolled under the run and came back: the run went on,
568
- // but the minutes spent waiting for the wake are friction the deploy
569
- // window owns.
567
+ // The container rolled under the run: on pi the run ends here and its
568
+ // request starts over (harness-pi item 16); on the deleted native loop
569
+ // it went on after the wake. Either way the roll's cost is friction the
570
+ // deploy window owns.
570
571
  findings.push({
571
572
  category: "infra_failure",
572
573
  severity: "medium",
@@ -15,6 +15,19 @@ export { isRunSession, SESSION_KEY_PATTERN, type RunSession } from "../runRecord
15
15
  /** The byte policy's default: `RetentionPolicy.sessionLogMaxBytes`. */
16
16
  export const DEFAULT_SESSION_LOG_MAX_BYTES = DEFAULT_RETENTION_POLICY.sessionLogMaxBytes;
17
17
 
18
+ /** How much of a hit's text a search answers with (item 10): one line, at most this many characters. */
19
+ export const SNIPPET_CHARS = 300;
20
+ /** The most hits one search answers — the object's cap, `recall`'s and the search route's alike. */
21
+ export const SEARCH_MAX_HITS = 50;
22
+
23
+ /** A hit's text as one line of at most `SNIPPET_CHARS` — what `recall` and
24
+ * the session search route answer beside the turn, so a reader sees where
25
+ * the words fell without the turn's whole body. */
26
+ export function snippetOf(text: string): string {
27
+ const line = text.replace(/\s+/g, " ").trim();
28
+ return line.length > SNIPPET_CHARS ? `${line.slice(0, SNIPPET_CHARS - 1)}…` : line;
29
+ }
30
+
18
31
  /** The object's name: the thread and the agent, the pair record 0034 calls a
19
32
  * session. A run without a resolved agent keys on a dash so the name still
20
33
  * has both halves. */
@@ -69,7 +69,12 @@ export interface RunRecord {
69
69
  model?: string;
70
70
  /** Platform-namespaced ids (AGENTS.md invariant 4). */
71
71
  channelId: string;
72
+ /** The person the run was for — the message's sender, or the person an app
73
+ * relayed it for (slack-channel.md item 13); `slack:bot:<id>` only when no
74
+ * person could be found behind an app's post. */
72
75
  userId: string;
76
+ /** The app that posted the request for `userId`, by display name, when it was not their own message. */
77
+ relayedBy?: string;
73
78
  threadKey: string;
74
79
  /** How the run's channel may travel (authorization): stamped at dispatch
75
80
  * from the `ChannelDirectory`, read by `member-of` (a `public` run is
@@ -101,6 +106,11 @@ export interface RunRecord {
101
106
  stepCount?: number;
102
107
  schema?: number;
103
108
  status: RunStatus;
109
+ /** The failure by name, when a `failed` run has one (item 57):
110
+ * `policy_refusal`, the provider refused the run's model call under its
111
+ * usage policy. Absent on a run that did not fail, on one that failed for
112
+ * a reason without a name here, and on records written before the field. */
113
+ failure?: RunFailure;
104
114
  /** Events the run published in total — unchanged by truncation. */
105
115
  eventCount: number;
106
116
  /** Events actually present in `events` (= `events.length`). */
@@ -293,6 +303,23 @@ function isRunPullRequestShape(v: unknown): v is RunPullRequest {
293
303
  );
294
304
  }
295
305
 
306
+ /** Why a `failed` run failed, when the failure has a name a reader acts on
307
+ * (item 57). `policy_refusal`: the model provider refused the run's call
308
+ * under its usage policy — the stop reason its wire names, never the
309
+ * explanation's words — so the session's next seed leaves the refused
310
+ * request out of its tail (docs/reference/specs/session-log.md item 9). A
311
+ * failure without a name here leaves the record without the field. */
312
+ export const RUN_FAILURE_KINDS = ["policy_refusal"] as const;
313
+ export type RunFailureKind = (typeof RUN_FAILURE_KINDS)[number];
314
+ export interface RunFailure {
315
+ kind: RunFailureKind;
316
+ }
317
+
318
+ export function isRunFailure(v: unknown): v is RunFailure {
319
+ if (typeof v !== "object" || v === null) return false;
320
+ return RUN_FAILURE_KINDS.includes((v as Record<string, unknown>).kind as RunFailureKind);
321
+ }
322
+
296
323
  /** The three places a run's conversation can start (item 52): the thread's
297
324
  * channel history, a spawning parent's text turns, or the tail of its own
298
325
  * session's log (docs/reference/specs/session-log.md item 9). */
@@ -426,6 +453,9 @@ export interface RunListOptions {
426
453
  /** One thread's runs (`slack:C0123:1712.34`), newest first — the read behind
427
454
  * a thread's lineage and a child's thread-aware rows (agent-conductor item 10). */
428
455
  threadKey?: string;
456
+ /** The runs one run spawned or that continue a thread it opened
457
+ * (`RunRecord.parentRunId`, item 46) — a conductor's children as one listing. */
458
+ parentRunId?: string;
429
459
  /** What the ACTOR may see (authorization): the store predicate compiled
430
460
  * from the policy, pushed down so no surface loads rows and filters after.
431
461
  * Absent = no visibility constraint — only a caller that has already decided
@@ -719,6 +749,8 @@ export function isRunRecord(v: unknown): v is RunRecord {
719
749
  if (r.seed !== undefined && !RUN_SEEDS.includes(r.seed as RunSeed)) return false;
720
750
  // The run's place in its session's log (item 53), or absent.
721
751
  if (r.session !== undefined && !isRunSession(r.session)) return false;
752
+ // The failure by name (item 57): one of the named kinds, or absent.
753
+ if (r.failure !== undefined && !isRunFailure(r.failure)) return false;
722
754
  if (r.usage !== undefined && !isRunUsage(r.usage)) return false;
723
755
  // A coordinator's child (item 48): the instance id in the platform's alphabet
724
756
  // and the key `<instance>:<step>` — both or neither; one alone is no tag.
@@ -734,6 +766,7 @@ export function isRunRecord(v: unknown): v is RunRecord {
734
766
  )
735
767
  return false;
736
768
  if (typeof r.channelId !== "string" || typeof r.userId !== "string" || typeof r.threadKey !== "string") return false;
769
+ if (r.relayedBy !== undefined && typeof r.relayedBy !== "string") return false;
737
770
  // Absent on records written before the stamp existed (read as `unknown`); present → a known value.
738
771
  if (r.channelVisibility !== undefined && !CHANNEL_VISIBILITIES.includes(r.channelVisibility as ChannelVisibility))
739
772
  return false;
@@ -22,6 +22,8 @@ export interface AttrDomain {
22
22
  caughtUp: boolean;
23
23
  files: number;
24
24
  dedupe: "fresh" | "duplicate";
25
+ /** How the requester was found (slack-channel.md item 13): the sender, the relay footer's thread, the thread's parent, or the app itself. */
26
+ requester: "message" | "relay-footer" | "thread-parent" | "bot";
25
27
  // dispatch.* / run.* / post.*
26
28
  outcome: string;
27
29
  count: number;
@@ -151,6 +153,7 @@ const ATTR_TYPE: Record<SpanAttrKey, "string" | "number" | "boolean"> = {
151
153
  caughtUp: "boolean",
152
154
  files: "number",
153
155
  dedupe: "string",
156
+ requester: "string",
154
157
  outcome: "string",
155
158
  count: "number",
156
159
  backend: "string",
@@ -44,6 +44,7 @@ export const STREAMED_SPANS = [
44
44
  "run.reading_diff",
45
45
  "run.settle_reviewed_head",
46
46
  "run.description_turn",
47
+ "run.verdict_turn",
47
48
  "run.observe_workspace",
48
49
  "run.pr_post_step",
49
50
  "run.review_post_step",
@@ -105,6 +106,7 @@ const UNCOUNTED: ReadonlySet<string> = new Set([
105
106
  "ship.round",
106
107
  "run.settle_reviewed_head",
107
108
  "run.description_turn",
109
+ "run.verdict_turn",
108
110
  "post.card_close",
109
111
  "post.reply",
110
112
  ]);
@@ -148,11 +150,12 @@ export const PARENTS: Readonly<Record<string, readonly string[]>> = {
148
150
  "dispatch.ship_preflight": ["request"],
149
151
  "dispatch.ledger_claim": ["request"],
150
152
  "dispatch.route": ["request"],
151
- "run.agent": ["request", "ship.round", "run.settle_reviewed_head", "run.description_turn"],
153
+ "run.agent": ["request", "ship.round", "run.settle_reviewed_head", "run.description_turn", "run.verdict_turn"],
152
154
  "run.command": ["request"],
153
155
  "run.reading_diff": ["request"],
154
156
  "run.settle_reviewed_head": ["request", "ship.round"],
155
157
  "run.description_turn": ["request", "ship.round"],
158
+ "run.verdict_turn": ["request", "ship.round"],
156
159
  "run.observe_workspace": ["request", "ship.round"],
157
160
  "run.pr_post_step": ["request", "ship.round"],
158
161
  "run.review_post_step": ["request", "ship.round"],