omp-conductor 0.20.1 → 0.20.3

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.
package/src/fleet.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  lockPidAlive,
38
38
  pidAlive,
39
39
  readTelegramDmOwner,
40
+ readTelegramMisroutes,
40
41
  readTelegramPollState,
41
42
  resolveClaimedSessionFile,
42
43
  resolveProjectTopicId,
@@ -64,6 +65,7 @@ export {
64
65
  consumeDrain,
65
66
  createDrain,
66
67
  drainPath,
68
+ markDrained,
67
69
  readDrain,
68
70
  type CreateDrainOptions,
69
71
  type DrainProblem,
@@ -435,9 +437,12 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
435
437
  // escalations already do (#318), following the bridge's current claim when the
436
438
  // pinned id has gone stale (#407). Missing project config keeps flat-chat 0.13.
437
439
  let sendTopic: number | undefined;
440
+ let sendProject: { name: string; workspaceRoot?: string } | undefined;
438
441
  if (named !== undefined) {
439
442
  try {
440
- sendTopic = resolveProjectTopicId(findProject(loadConfig(), named), deps.pidAlive ?? pidAlive);
443
+ const cfg = findProject(loadConfig(), named);
444
+ sendTopic = resolveProjectTopicId(cfg, deps.pidAlive ?? pidAlive);
445
+ sendProject = { name: cfg.name, workspaceRoot: cfg.workspaceRoot };
441
446
  } catch {
442
447
  /* no project config */
443
448
  }
@@ -510,7 +515,7 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
510
515
  owner: channel.owner,
511
516
  });
512
517
  try {
513
- await send(token, channel.owner, text, sendTopic);
518
+ await send(token, channel.owner, text, sendTopic, sendProject);
514
519
  } catch (err) {
515
520
  // The challenge never went out, so its transaction must not linger as a
516
521
  // classifiable proof either.
@@ -628,9 +633,12 @@ export async function armFleet(
628
633
  // operator is reading, and the reply step reaches the record from any project
629
634
  // because it is fleet-wide rather than topic-scoped.
630
635
  let sendTopic: number | undefined;
636
+ let sendProject: { name: string; workspaceRoot?: string } | undefined;
631
637
  for (const target of targets) {
632
638
  try {
633
- sendTopic = resolveProjectTopicId(findProject(loadConfig(), target.project));
639
+ const cfg = findProject(loadConfig(), target.project);
640
+ sendTopic = resolveProjectTopicId(cfg);
641
+ sendProject = { name: cfg.name, workspaceRoot: cfg.workspaceRoot };
634
642
  } catch {
635
643
  continue; /* no project config — try the next */
636
644
  }
@@ -664,7 +672,7 @@ export async function armFleet(
664
672
  owner: channel.owner,
665
673
  });
666
674
  try {
667
- await send(token, channel.owner, text, sendTopic);
675
+ await send(token, channel.owner, text, sendTopic, sendProject);
668
676
  } catch (err) {
669
677
  clearArmTransaction(FLEET_ARM_KEY, challengeId);
670
678
  throw new Error(
@@ -1112,7 +1120,7 @@ export interface WorkerPaneDeps {
1112
1120
  run?: HerdrRun;
1113
1121
  session?: string;
1114
1122
  /** The follower command the pane displays; the CLI by default. */
1115
- viewer?: (identity: WorkerPaneIdentity) => readonly string[];
1123
+ viewer?: (identity: PaneCeremonyIdentity) => readonly string[];
1116
1124
  /** The durable ownership half of workspace discovery (#1035 review);
1117
1125
  * production wires the conductor store, tests a recorder. */
1118
1126
  ownership?: WorkspaceOwnership;
@@ -1170,7 +1178,17 @@ export function workerWorkspaceLabel(project: string): string {
1170
1178
  return `${project}-workers`;
1171
1179
  }
1172
1180
 
1173
- export function workerPaneLabel(identity: WorkerPaneIdentity): string {
1181
+ /** The identity fields a representation's ceremony actually reports (#1110).
1182
+ * The spawn pid rides on {@link WorkerPaneIdentity} for the record; naming,
1183
+ * attribution and the viewer command never read it, so the retile's
1184
+ * transplant can re-establish a representation without pretending to know a
1185
+ * pid it cannot prove. */
1186
+ type PaneCeremonyIdentity = Pick<WorkerPaneIdentity, "project" | "issue" | "attempt" | "runId"> & {
1187
+ /** The spawn pid, when the caller holds one; the ceremony never reads it. */
1188
+ pid?: number;
1189
+ sessionFile?: string;
1190
+ };
1191
+ export function workerPaneLabel(identity: PaneCeremonyIdentity): string {
1174
1192
  return `worker-${identity.project}-${identity.issue}-a${identity.attempt}-${identity.runId.slice(0, 8)}`;
1175
1193
  }
1176
1194
 
@@ -1184,7 +1202,7 @@ const realHerdrRun: HerdrRun = (args) => {
1184
1202
  };
1185
1203
 
1186
1204
  /** `omp-conductor tail` — read-only by construction, and never parsed here. */
1187
- function defaultViewer(identity: WorkerPaneIdentity): readonly string[] {
1205
+ function defaultViewer(identity: PaneCeremonyIdentity): readonly string[] {
1188
1206
  return ["omp-conductor", "tail", String(identity.issue), "--project", identity.project];
1189
1207
  }
1190
1208
 
@@ -1301,6 +1319,8 @@ export interface ListedPane {
1301
1319
  runId: string;
1302
1320
  agent?: string;
1303
1321
  workspaceId?: string;
1322
+ /** The tab holding this pane, when Herdr reports one (#1110). */
1323
+ tabId?: string;
1304
1324
  }
1305
1325
 
1306
1326
  /** The panes of a `pane list` answer, carrying whichever fields reconciliation reads. */
@@ -1324,11 +1344,73 @@ function parsePaneRecords(stdout: string): { panes: ListedPane[] } | undefined {
1324
1344
  runId: typeof value === "string" && source === WORKER_PANE_SOURCE ? value : "",
1325
1345
  ...(typeof p["agent"] === "string" ? { agent: p["agent"] } : {}),
1326
1346
  ...(typeof p["workspace_id"] === "string" ? { workspaceId: p["workspace_id"] } : {}),
1347
+ ...(typeof p["tab_id"] === "string" ? { tabId: p["tab_id"] } : {}),
1327
1348
  });
1328
1349
  }
1329
1350
  return { panes };
1330
1351
  }
1331
1352
 
1353
+ /** The new tab inside a `tab create` answer (`result.tab`, `result.root_pane`). */
1354
+ function parseCreatedTab(stdout: string): { tabId?: string; rootPaneId?: string } | undefined {
1355
+ const result = envelopeOf(stdout);
1356
+ if (result === undefined) return undefined;
1357
+ const tab = result["tab"];
1358
+ const root = result["root_pane"];
1359
+ const tabId =
1360
+ tab !== null && typeof tab === "object" ? (tab as Record<string, unknown>)["tab_id"] : undefined;
1361
+ const rootPaneId =
1362
+ root !== null && typeof root === "object" ? (root as Record<string, unknown>)["pane_id"] : undefined;
1363
+ const id = typeof tabId === "string" && tabId.trim() !== "" ? tabId.trim() : undefined;
1364
+ const rootId =
1365
+ typeof rootPaneId === "string" && rootPaneId.trim() !== "" ? rootPaneId.trim() : undefined;
1366
+ if (id === undefined && rootId === undefined) return undefined;
1367
+ return {
1368
+ ...(id === undefined ? {} : { tabId: id }),
1369
+ ...(rootId === undefined ? {} : { rootPaneId: rootId }),
1370
+ };
1371
+ }
1372
+
1373
+ /** One pane's rectangle out of a `pane layout` answer. */
1374
+ interface PaneRect {
1375
+ paneId: string;
1376
+ /** Top row inside the tab; ascending top to bottom. */
1377
+ y: number;
1378
+ height: number;
1379
+ }
1380
+
1381
+ /**
1382
+ * Every pane rect of the tab a `pane layout --pane` answer describes (#1110) —
1383
+ * one call measures the whole tab. An answer carrying no usable rects is
1384
+ * unreadable, never an excuse to mutate blind.
1385
+ */
1386
+ function parsePaneLayout(stdout: string): { tabId?: string; panes: PaneRect[] } | undefined {
1387
+ const result = envelopeOf(stdout);
1388
+ const layout = result?.["layout"];
1389
+ if (layout === null || typeof layout !== "object") return undefined;
1390
+ const record = layout as Record<string, unknown>;
1391
+ const raw = record["panes"];
1392
+ if (!Array.isArray(raw)) return undefined;
1393
+ const panes: PaneRect[] = [];
1394
+ for (const entry of raw) {
1395
+ if (entry === null || typeof entry !== "object") continue;
1396
+ const pane = entry as Record<string, unknown>;
1397
+ const rect = pane["rect"];
1398
+ if (typeof pane["pane_id"] !== "string" || pane["pane_id"].trim() === "") continue;
1399
+ if (rect === null || typeof rect !== "object") continue;
1400
+ const height = (rect as Record<string, unknown>)["height"];
1401
+ const y = (rect as Record<string, unknown>)["y"];
1402
+ if (typeof height !== "number" || !Number.isFinite(height)) continue;
1403
+ if (typeof y !== "number" || !Number.isFinite(y)) continue;
1404
+ panes.push({ paneId: pane["pane_id"].trim(), height, y });
1405
+ }
1406
+ if (panes.length === 0) return undefined;
1407
+ const tabId = record["tab_id"];
1408
+ return {
1409
+ ...(typeof tabId === "string" && tabId.trim() !== "" ? { tabId: tabId.trim() } : {}),
1410
+ panes,
1411
+ };
1412
+ }
1413
+
1332
1414
  /** One conductor-marked worker workspace, as discovery returns it. */
1333
1415
  export interface WorkerWorkspace {
1334
1416
  workspaceId: string;
@@ -1549,6 +1631,64 @@ export function ensureWorkerWorkspace(
1549
1631
  return { kind: "ready", workspaceId: parsed.workspaceId, anchorPaneId: parsed.rootPaneId, created: true };
1550
1632
  }
1551
1633
 
1634
+ /**
1635
+ * Name a pane for its run, report the run's identity onto it, start the
1636
+ * read-only follower, and report the working state — the ceremony every fresh
1637
+ * representation goes through, shared verbatim by {@link openWorkerPane} and
1638
+ * the #1110 retile's identity transplant. Reasons come back exactly as the
1639
+ * inline steps worded them, because callers surface them unchanged.
1640
+ */
1641
+ function establishWorkerPane(
1642
+ paneId: string,
1643
+ identity: PaneCeremonyIdentity,
1644
+ deps: WorkerPaneDeps & { session: string },
1645
+ ): { ok: true; label: string } | { ok: false; reason: string } {
1646
+ const run = deps.run ?? realHerdrRun;
1647
+ const base = ["--session", deps.session, "pane"];
1648
+ const label = workerPaneLabel(identity);
1649
+
1650
+ const named = run([...base, "rename", paneId, label]);
1651
+ if (!named.ok) {
1652
+ return { ok: false, reason: `herdr pane rename failed: ${firstLine(named.stderr) || "no output"}` };
1653
+ }
1654
+
1655
+ // Identity before display: the pane must be attributable to this exact run
1656
+ // before it shows anything, so a pane that appears is never a pane nobody can
1657
+ // trace back to a worker.
1658
+ const identified = run([
1659
+ ...base,
1660
+ "report-agent-session",
1661
+ paneId,
1662
+ "--source",
1663
+ WORKER_PANE_SOURCE,
1664
+ "--agent",
1665
+ label,
1666
+ "--agent-session-id",
1667
+ identity.runId,
1668
+ ...(identity.sessionFile === undefined ? [] : ["--agent-session-path", identity.sessionFile]),
1669
+ "--session-start-source",
1670
+ WORKER_PANE_SOURCE,
1671
+ ]);
1672
+ if (!identified.ok) {
1673
+ return {
1674
+ ok: false,
1675
+ reason: `herdr pane report-agent-session failed: ${firstLine(identified.stderr) || "no output"}`,
1676
+ };
1677
+ }
1678
+
1679
+ const started = run([...base, "run", paneId, ...(deps.viewer?.(identity) ?? defaultViewer(identity))]);
1680
+ if (!started.ok) {
1681
+ return { ok: false, reason: `herdr pane run failed: ${firstLine(started.stderr) || "no output"}` };
1682
+ }
1683
+
1684
+ // A worker that has just spawned is working by definition. The ongoing
1685
+ // projection of turn/pause/blocked transitions is #842's, from the same typed
1686
+ // events — never from the pane's output.
1687
+ const reported = reportWorkerPaneState(paneId, label, "working", { run, session: deps.session });
1688
+ if (!reported.ok) return { ok: false, reason: reported.reason };
1689
+ return { ok: true, label };
1690
+ }
1691
+
1552
1692
  /**
1553
1693
  * Create the pane, run the follower in it, and report the child's identity and
1554
1694
  * initial state — or say exactly why it could not.
@@ -1610,49 +1750,38 @@ export function openWorkerPane(identity: WorkerPaneIdentity, deps: WorkerPaneDep
1610
1750
  return abandon("herdr pane split reported no pane id");
1611
1751
  }
1612
1752
 
1613
- const named = run([...base, "rename", paneId, label]);
1614
- if (!named.ok) {
1615
- return abandon(`herdr pane rename failed: ${firstLine(named.stderr) || "no output"}`, undefined, paneId);
1616
- }
1617
-
1618
- // Identity before display: the pane must be attributable to this exact run
1619
- // before it shows anything, so a pane that appears is never a pane nobody can
1620
- // trace back to a worker.
1621
- const identified = run([
1622
- ...base,
1623
- "report-agent-session",
1624
- paneId,
1625
- "--source",
1626
- WORKER_PANE_SOURCE,
1627
- "--agent",
1628
- label,
1629
- "--agent-session-id",
1630
- identity.runId,
1631
- ...(identity.sessionFile === undefined ? [] : ["--agent-session-path", identity.sessionFile]),
1632
- "--session-start-source",
1633
- WORKER_PANE_SOURCE,
1634
- ]);
1635
- if (!identified.ok) {
1636
- return abandon(
1637
- `herdr pane report-agent-session failed: ${firstLine(identified.stderr) || "no output"}`,
1638
- undefined,
1639
- paneId,
1640
- );
1641
- }
1642
-
1643
- const started = run([...base, "run", paneId, ...deps.viewer?.(identity) ?? defaultViewer(identity)]);
1644
- if (!started.ok) {
1645
- return abandon(`herdr pane run failed: ${firstLine(started.stderr) || "no output"}`, undefined, paneId);
1646
- }
1753
+ const established = establishWorkerPane(paneId, identity, { ...deps, session });
1754
+ if (!established.ok) {
1755
+ return abandon(established.reason, undefined, paneId);
1756
+ }
1757
+
1758
+ // Layout hygiene runs last, so a refused retile can never fail the launch
1759
+ // it follows (#1110). A workspace this call itself created holds one worker
1760
+ // beside its bare skeleton — nothing to rebalance yet; the junk sweep takes
1761
+ // the skeleton and later passes keep the tab even. When a rebuilt tab's
1762
+ // skeleton carries this run's identity, the tracked outcome names the pane
1763
+ // that now represents it.
1764
+ const retiled: WorkerTabRebalance =
1765
+ ensured.created === true
1766
+ ? { ok: true, rebuilt: false }
1767
+ : rebalanceWorkerTabs(ensured.workspaceId, {
1768
+ run,
1769
+ session,
1770
+ ...(deps.viewer === undefined ? {} : { viewer: deps.viewer }),
1771
+ reidentify: (candidate) => (candidate === paneId ? identity : undefined),
1772
+ });
1773
+ const displayed =
1774
+ retiled.ok && retiled.transplanted !== undefined && retiled.transplanted.fromPaneId === paneId
1775
+ ? retiled.transplanted.toPaneId
1776
+ : paneId;
1647
1777
 
1648
- // A worker that has just spawned is working by definition. The ongoing
1649
- // projection of turn/pause/blocked transitions is #842's, from the same typed
1650
- // events — never from the pane's output.
1651
- const reported = reportWorkerPaneState(paneId, label, "working", { run, session });
1652
- if (!reported.ok) {
1653
- return abandon(reported.reason, undefined, paneId);
1654
- }
1655
- return { kind: "tracked", paneId, label, pid: identity.pid, workspaceId: ensured.workspaceId };
1778
+ return {
1779
+ kind: "tracked",
1780
+ paneId: displayed,
1781
+ label: established.label,
1782
+ pid: identity.pid,
1783
+ workspaceId: ensured.workspaceId,
1784
+ };
1656
1785
  }
1657
1786
 
1658
1787
  /** Report one lifecycle state for a tracked pane. Monotonic `seq` is the caller's. */
@@ -1754,6 +1883,211 @@ export function retireWorkerPane(
1754
1883
  return closeWorkerPane(paneId, deps);
1755
1884
  }
1756
1885
 
1886
+ /**
1887
+ * How far the tallest pane may sit above the shortest before a tab counts as
1888
+ * crooked (#1110). Five workers attached by repeated fixed-ratio splits of one
1889
+ * anchor come out geometrically smaller — roughly 30%, 21%, 15%, 10%, 7% of
1890
+ * the workspace — so the fifth lands far past this line; a freshly tiled tab
1891
+ * sits at about 1.0.
1892
+ */
1893
+ export const PANE_HEIGHT_TOLERANCE = 1.5;
1894
+
1895
+ /**
1896
+ * Split ratios that leave `slots` equal rows behind when every newcomer is
1897
+ * anchored on the same shrinking skeleton (#1110).
1898
+ *
1899
+ * Measured on a live Herdr: a split or move with `--ratio r` leaves the ANCHOR
1900
+ * an r share of itself and hands the newcomer the rest. Taking
1901
+ * `r_j = (slots - j) / (slots - j + 1)` for move j therefore hands each
1902
+ * newcomer exactly one slot of the whole — the arithmetic that turns the fifth
1903
+ * attach from a fourteenth of the height into a fifth.
1904
+ */
1905
+ export function evenTileRatios(slots: number): readonly number[] {
1906
+ if (!Number.isInteger(slots) || slots < 2) return [];
1907
+ const ratios: number[] = [];
1908
+ for (let j = 1; j <= slots - 1; j++) ratios.push((slots - j) / (slots - j + 1));
1909
+ return ratios;
1910
+ }
1911
+
1912
+ export type WorkerTabRebalance =
1913
+ | {
1914
+ ok: true;
1915
+ /** True when at least one crooked tab was rebuilt by this call. */
1916
+ rebuilt: boolean;
1917
+ /** The representation that rode the rebuilt tab's skeleton, when the old
1918
+ * tab could only be disposed of by re-establishing one run's visual. */
1919
+ transplanted?: { fromPaneId: string; toPaneId: string; identity: PaneCeremonyIdentity };
1920
+ }
1921
+ | { ok: false; reason: string };
1922
+
1923
+ /**
1924
+ * Retile every crooked tab of one worker workspace into equal rows (#1110).
1925
+ * Repeated fixed-ratio splits of a single anchor make each new worker
1926
+ * geometrically smaller than the last, and a settling pane hands its space to
1927
+ * a split sibling instead of sharing it out — attaches and detaches both
1928
+ * drift. `pane resize` was probed live and rejected: it moves borders through
1929
+ * the split tree proportionally, so nudges cascade across neighbours, invert
1930
+ * under sign, and wedge into states where they no-op forever. The honest
1931
+ * rebuild moves the standing panes — ids, followers and scrollback surviving
1932
+ * the move — into a fresh tab whose sequential anchor ratios land them even,
1933
+ * re-establishes one run's representation on the skeleton root so no bare
1934
+ * shell remains (a closed skeleton dumps its whole share into one split
1935
+ * sibling, which was measured skewing a six-pane tab back to 11:6), and
1936
+ * closes the emptied tab.
1937
+ *
1938
+ * Only callers that already own the workspace invoke this: reconciliation
1939
+ * scopes it to token-marked or store-recorded surfaces (#1035), and
1940
+ * {@link openWorkerPane} calls it for the workspace it just found or created
1941
+ * through the same discovery. No pane is created, written to, or given input:
1942
+ * a move re-parents the terminal whole, and the one follower that restarts
1943
+ * (the transplanted run's) restarts the same read-only `omp-conductor tail`.
1944
+ *
1945
+ * Best effort by design. Any refusal stops the tab's rebuild where it stands
1946
+ * and is reported; partial rebuilds converge because already-moved panes
1947
+ * measure even in their new tab, so each retry moves strictly fewer panes.
1948
+ * Callers treat a failure as next pass's identical request, never as a launch
1949
+ * or settlement problem: a crooked layout never outweighs the representation
1950
+ * it hosts.
1951
+ */
1952
+ export function rebalanceWorkerTabs(
1953
+ workspaceId: string,
1954
+ deps: {
1955
+ run?: HerdrRun;
1956
+ session?: string;
1957
+ viewer?: (identity: PaneCeremonyIdentity) => readonly string[];
1958
+ /** Whose representation a pane carries, as far as the caller knows. A pane
1959
+ * nobody can identify is never chosen to ride the skeleton. */
1960
+ reidentify?: (paneId: string) => PaneCeremonyIdentity | undefined;
1961
+ } = {},
1962
+ ): WorkerTabRebalance {
1963
+ const run = deps.run ?? realHerdrRun;
1964
+ const session = deps.session ?? resolveHerdrSession();
1965
+ const base = ["--session", session];
1966
+
1967
+ const res = run([...base, "pane", "list", "--workspace", workspaceId]);
1968
+ if (!res.ok) {
1969
+ return { ok: false, reason: `herdr pane list failed: ${firstLine(res.stderr) || "no output"}` };
1970
+ }
1971
+ const parsed = parsePaneRecords(res.stdout);
1972
+ if (parsed === undefined) return { ok: false, reason: "herdr pane list was unreadable" };
1973
+
1974
+ /** Tab id → member pane ids, in listing order. */
1975
+ const tabs = new Map<string, string[]>();
1976
+ for (const pane of parsed.panes) {
1977
+ const members = tabs.get(pane.tabId ?? "");
1978
+ if (members === undefined) tabs.set(pane.tabId ?? "", [pane.paneId]);
1979
+ else members.push(pane.paneId);
1980
+ }
1981
+
1982
+ let rebuilt = false;
1983
+ for (const [tabId, paneIds] of tabs) {
1984
+ // A tab Herdr did not name cannot be torn down safely after the rebuild,
1985
+ // so measuring it is where its story ends.
1986
+ if (tabId === "" || paneIds.length < 2) continue;
1987
+ const measured = run([...base, "pane", "layout", "--pane", paneIds[0]!]);
1988
+ if (!measured.ok) {
1989
+ return { ok: false, reason: `herdr pane layout failed: ${firstLine(measured.stderr) || "no output"}` };
1990
+ }
1991
+ const layout = parsePaneLayout(measured.stdout);
1992
+ if (layout === undefined) return { ok: false, reason: "herdr pane layout was unreadable" };
1993
+ const rectOf = new Map(layout.panes.map((rect) => [rect.paneId, rect]));
1994
+ const rects = paneIds.map((id) => rectOf.get(id)).filter((rect): rect is PaneRect => rect !== undefined);
1995
+ // A listed pane the layout does not describe is a tab changing under us:
1996
+ // mutate nothing on half evidence.
1997
+ if (rects.length !== paneIds.length) continue;
1998
+ const heights = rects.map((rect) => rect.height);
1999
+ const shortest = Math.min(...heights);
2000
+ const tallest = Math.max(...heights);
2001
+ // A zero-height pane is crooked beside anything alive; the tolerance is
2002
+ // inclusive at exactly 1.5×, since the criterion reads "no more than".
2003
+ // Below three average cells a pane is illegible whatever the ratio —
2004
+ // tiling cannot help a terminal that small.
2005
+ if (tallest <= shortest * PANE_HEIGHT_TOLERANCE) continue;
2006
+ if (heights.reduce((sum, h) => sum + h, 0) / heights.length < 3) continue;
2007
+
2008
+ // The rider is the topmost pane the caller can identify; it re-founds its
2009
+ // representation on the skeleton, which lands as the tab's top row, so the
2010
+ // operator's reading order survives. The rest move bottom-to-top,
2011
+ // reversing into their original order underneath it.
2012
+ const ascending = [...rects].sort((a, b) => a.y - b.y);
2013
+ const rider = ascending.find((rect) => deps.reidentify?.(rect.paneId) !== undefined);
2014
+ if (rider === undefined) continue;
2015
+ const identity = deps.reidentify!(rider.paneId)!;
2016
+ const movers = ascending.filter((rect) => rect.paneId !== rider.paneId).reverse();
2017
+
2018
+ const made = run([...base, "tab", "create", "--workspace", workspaceId, "--no-focus"]);
2019
+ if (!made.ok) {
2020
+ return { ok: false, reason: `herdr tab create failed: ${firstLine(made.stderr) || "no output"}` };
2021
+ }
2022
+ const created = parseCreatedTab(made.stdout);
2023
+ if (created?.tabId === undefined || created.rootPaneId === undefined) {
2024
+ // Half an answer: the tab may exist and must not linger as an empty
2025
+ // shell this attempt cannot account for.
2026
+ if (created?.tabId !== undefined) run([...base, "tab", "close", created.tabId]);
2027
+ return { ok: false, reason: "herdr tab create reported no root pane" };
2028
+ }
2029
+
2030
+ const slots = movers.length + 1;
2031
+ const ratios = evenTileRatios(slots);
2032
+ let placed = 0;
2033
+ for (; placed < movers.length; placed++) {
2034
+ const moved = run([
2035
+ ...base,
2036
+ "pane",
2037
+ "move",
2038
+ movers[placed]!.paneId,
2039
+ "--tab",
2040
+ created.tabId,
2041
+ "--split",
2042
+ "down",
2043
+ "--target-pane",
2044
+ created.rootPaneId,
2045
+ "--ratio",
2046
+ String(ratios[placed]),
2047
+ ]);
2048
+ if (!moved.ok) {
2049
+ return { ok: false, reason: `herdr pane move failed: ${firstLine(moved.stderr) || "no output"}` };
2050
+ }
2051
+ }
2052
+
2053
+ // The skeleton kept the top slot only by inheriting the rider's identity:
2054
+ // the same name, the same session report, the same read-only tail. A bare
2055
+ // shell up there would be junk, and junk closure is exactly what skews.
2056
+ const founded = establishWorkerPane(created.rootPaneId, identity, {
2057
+ run,
2058
+ session,
2059
+ ...(deps.viewer === undefined ? {} : { viewer: deps.viewer }),
2060
+ });
2061
+ if (!founded.ok) {
2062
+ return { ok: false, reason: founded.reason };
2063
+ }
2064
+ const retiredRider = run([...base, "pane", "close", rider.paneId]);
2065
+ if (!retiredRider.ok) {
2066
+ return { ok: false, reason: `herdr pane close failed: ${firstLine(retiredRider.stderr) || "no output"}` };
2067
+ }
2068
+ // Herdr tears an emptied tab down by itself (probed live), so a refusal
2069
+ // here is only fatal when panes still stand in the old tab — verified,
2070
+ // never assumed.
2071
+ const closed = run([...base, "tab", "close", tabId]);
2072
+ if (!closed.ok) {
2073
+ const verify = run([...base, "pane", "list", "--workspace", workspaceId]);
2074
+ const leftover = verify.ok
2075
+ ? (parsePaneRecords(verify.stdout)?.panes ?? []).filter((pane) => pane.tabId === tabId)
2076
+ : null;
2077
+ if (leftover === null || leftover.length > 0) {
2078
+ return { ok: false, reason: `herdr tab close failed: ${firstLine(closed.stderr) || "no output"}` };
2079
+ }
2080
+ }
2081
+ rebuilt = true;
2082
+ return {
2083
+ ok: true,
2084
+ rebuilt: true,
2085
+ transplanted: { fromPaneId: rider.paneId, toPaneId: created.rootPaneId, identity },
2086
+ };
2087
+ }
2088
+ return { ok: true, rebuilt };
2089
+ }
2090
+
1757
2091
  /**
1758
2092
  * Hand back the representation a dead worker left behind (#842), retired for
1759
2093
  * good by closing it (#1035) — but only once the recorded pane is PROVEN still
@@ -2273,6 +2607,37 @@ export function reconcileWorkerPanes(
2273
2607
  });
2274
2608
  }
2275
2609
 
2610
+ // Attach and detach both leave geometry behind: repeated fixed-ratio splits
2611
+ // shrink every new pane geometrically (#1110), and a settle hands its space
2612
+ // to a split sibling instead of sharing it out. Every scoped workspace that
2613
+ // still holds two or more panes gets measured now; a tab past the tolerance
2614
+ // is rebuilt evenly — same panes, same ids, followers untouched. The one
2615
+ // representation riding the skeleton keeps its reported identity, so the
2616
+ // next pass attributes it through the session id exactly as #1035 already
2617
+ // requires; this pass deliberately says nothing about layout, because a
2618
+ // crooked tile is cosmetics and an outcome row would claim otherwise.
2619
+ for (const entry of entries) {
2620
+ let standingInWorkspace = 0;
2621
+ for (const wsId of standingWs.values()) if (wsId === entry.ws.workspaceId) standingInWorkspace += 1;
2622
+ if (standingInWorkspace < 2) continue;
2623
+ rebalanceWorkerTabs(entry.ws.workspaceId, {
2624
+ run,
2625
+ session,
2626
+ ...(deps.viewer === undefined ? {} : { viewer: deps.viewer }),
2627
+ reidentify: (paneId) => {
2628
+ const worker = live.find((candidate) => candidate.paneId === paneId);
2629
+ if (worker === undefined) return undefined;
2630
+ return {
2631
+ project: worker.project,
2632
+ issue: worker.issue,
2633
+ attempt: worker.attempt,
2634
+ runId: worker.runId,
2635
+ ...(worker.sessionFile === undefined ? {} : { sessionFile: worker.sessionFile }),
2636
+ };
2637
+ },
2638
+ });
2639
+ }
2640
+
2276
2641
  // Every scoped marked workspace whose panes the cleanup above emptied, and
2277
2642
  // that gained no representation this pass, is closed — however many marked
2278
2643
  // surfaces a project accumulated (#1035 review). A live project's primary
@@ -3089,6 +3454,9 @@ export type FleetStatusReport = StatusSnapshot & {
3089
3454
  /** The project-scoped durable to-spec grooming lifecycle as status lines
3090
3455
  * (#809), or nothing when there is nothing to report. */
3091
3456
  grooming: string | undefined;
3457
+ /** The sends this project's stale pin already delivered to the flat chat
3458
+ * (#1094), or undefined when the pin is healthy or freshly repaired. */
3459
+ topicMisroute: string | undefined;
3092
3460
  lastStop: DaemonStop | undefined;
3093
3461
  siblings: { project: string; live: number }[];
3094
3462
  };
@@ -3187,6 +3555,7 @@ export async function collectFleetStatus(projectName?: string): Promise<FleetSta
3187
3555
  workerPhases,
3188
3556
  intake: intakeStatusLine(project.name),
3189
3557
  grooming,
3558
+ topicMisroute: telegramMisrouteStatusLine(project),
3190
3559
  lastStop,
3191
3560
  siblings,
3192
3561
  };
@@ -3208,6 +3577,7 @@ export function renderFleetStatusReport(report: FleetStatusReport): string {
3208
3577
  report.lastStop,
3209
3578
  report.siblings,
3210
3579
  report.grooming,
3580
+ report.topicMisroute,
3211
3581
  );
3212
3582
  }
3213
3583
 
@@ -3215,6 +3585,26 @@ export async function renderStatus(projectName?: string): Promise<string> {
3215
3585
  return renderFleetStatusReport(await collectFleetStatus(projectName));
3216
3586
  }
3217
3587
 
3588
+ /**
3589
+ * One status line naming the sends this project's stale pin already delivered
3590
+ * to the flat chat (#1094) — the send-side evidence behind doctor's topic-pin
3591
+ * warning. Rows match the *current* pin id, so re-pinning retires the row
3592
+ * without anyone deleting history.
3593
+ */
3594
+ function telegramMisrouteStatusLine(project: ProjectConfig): string | undefined {
3595
+ const pinned = project.escalation.telegramTopicId;
3596
+ if (pinned === undefined) return undefined;
3597
+ const rows = readTelegramMisroutes().filter(
3598
+ (row) => row.project === project.name && row.staleTopicId === pinned,
3599
+ );
3600
+ if (rows.length === 0) return undefined;
3601
+ const last = new Date(rows[rows.length - 1]!.at).toISOString();
3602
+ return (
3603
+ `misroute ${rows.length} send(s) went to the flat chat because pinned topic ${pinned} was stale — ` +
3604
+ `last ${last}; re-pin escalation.telegramTopicId to clear`
3605
+ );
3606
+ }
3607
+
3218
3608
  /**
3219
3609
  * One line naming the brief layout, or nothing when it cannot be read.
3220
3610
  *
@@ -3612,9 +4002,13 @@ async function sendTelegramMessage(
3612
4002
  owner: string,
3613
4003
  text: string,
3614
4004
  topicId?: number,
4005
+ project?: { name: string; workspaceRoot?: string },
3615
4006
  ): Promise<void> {
3616
4007
  // Shared transport: stale-topic retry + message_thread_id live in one place.
3617
- await sendTelegram(token, owner, text, { topicId });
4008
+ // The project pair rides along (#1094), so a challenge whose pinned topic is
4009
+ // refused follows the project's live claim or lands labelled flat like every
4010
+ // other send on this seam.
4011
+ await sendTelegram(token, owner, text, { topicId, ...(project === undefined ? {} : { project }) });
3618
4012
  }
3619
4013
 
3620
4014
  // ---------------------------------------------------------------------------