omp-conductor 0.20.2 → 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
@@ -1120,7 +1120,7 @@ export interface WorkerPaneDeps {
1120
1120
  run?: HerdrRun;
1121
1121
  session?: string;
1122
1122
  /** The follower command the pane displays; the CLI by default. */
1123
- viewer?: (identity: WorkerPaneIdentity) => readonly string[];
1123
+ viewer?: (identity: PaneCeremonyIdentity) => readonly string[];
1124
1124
  /** The durable ownership half of workspace discovery (#1035 review);
1125
1125
  * production wires the conductor store, tests a recorder. */
1126
1126
  ownership?: WorkspaceOwnership;
@@ -1178,7 +1178,17 @@ export function workerWorkspaceLabel(project: string): string {
1178
1178
  return `${project}-workers`;
1179
1179
  }
1180
1180
 
1181
- 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 {
1182
1192
  return `worker-${identity.project}-${identity.issue}-a${identity.attempt}-${identity.runId.slice(0, 8)}`;
1183
1193
  }
1184
1194
 
@@ -1192,7 +1202,7 @@ const realHerdrRun: HerdrRun = (args) => {
1192
1202
  };
1193
1203
 
1194
1204
  /** `omp-conductor tail` — read-only by construction, and never parsed here. */
1195
- function defaultViewer(identity: WorkerPaneIdentity): readonly string[] {
1205
+ function defaultViewer(identity: PaneCeremonyIdentity): readonly string[] {
1196
1206
  return ["omp-conductor", "tail", String(identity.issue), "--project", identity.project];
1197
1207
  }
1198
1208
 
@@ -1309,6 +1319,8 @@ export interface ListedPane {
1309
1319
  runId: string;
1310
1320
  agent?: string;
1311
1321
  workspaceId?: string;
1322
+ /** The tab holding this pane, when Herdr reports one (#1110). */
1323
+ tabId?: string;
1312
1324
  }
1313
1325
 
1314
1326
  /** The panes of a `pane list` answer, carrying whichever fields reconciliation reads. */
@@ -1332,11 +1344,73 @@ function parsePaneRecords(stdout: string): { panes: ListedPane[] } | undefined {
1332
1344
  runId: typeof value === "string" && source === WORKER_PANE_SOURCE ? value : "",
1333
1345
  ...(typeof p["agent"] === "string" ? { agent: p["agent"] } : {}),
1334
1346
  ...(typeof p["workspace_id"] === "string" ? { workspaceId: p["workspace_id"] } : {}),
1347
+ ...(typeof p["tab_id"] === "string" ? { tabId: p["tab_id"] } : {}),
1335
1348
  });
1336
1349
  }
1337
1350
  return { panes };
1338
1351
  }
1339
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
+
1340
1414
  /** One conductor-marked worker workspace, as discovery returns it. */
1341
1415
  export interface WorkerWorkspace {
1342
1416
  workspaceId: string;
@@ -1557,6 +1631,64 @@ export function ensureWorkerWorkspace(
1557
1631
  return { kind: "ready", workspaceId: parsed.workspaceId, anchorPaneId: parsed.rootPaneId, created: true };
1558
1632
  }
1559
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
+
1560
1692
  /**
1561
1693
  * Create the pane, run the follower in it, and report the child's identity and
1562
1694
  * initial state — or say exactly why it could not.
@@ -1618,49 +1750,38 @@ export function openWorkerPane(identity: WorkerPaneIdentity, deps: WorkerPaneDep
1618
1750
  return abandon("herdr pane split reported no pane id");
1619
1751
  }
1620
1752
 
1621
- const named = run([...base, "rename", paneId, label]);
1622
- if (!named.ok) {
1623
- return abandon(`herdr pane rename failed: ${firstLine(named.stderr) || "no output"}`, undefined, paneId);
1624
- }
1625
-
1626
- // Identity before display: the pane must be attributable to this exact run
1627
- // before it shows anything, so a pane that appears is never a pane nobody can
1628
- // trace back to a worker.
1629
- const identified = run([
1630
- ...base,
1631
- "report-agent-session",
1632
- paneId,
1633
- "--source",
1634
- WORKER_PANE_SOURCE,
1635
- "--agent",
1636
- label,
1637
- "--agent-session-id",
1638
- identity.runId,
1639
- ...(identity.sessionFile === undefined ? [] : ["--agent-session-path", identity.sessionFile]),
1640
- "--session-start-source",
1641
- WORKER_PANE_SOURCE,
1642
- ]);
1643
- if (!identified.ok) {
1644
- return abandon(
1645
- `herdr pane report-agent-session failed: ${firstLine(identified.stderr) || "no output"}`,
1646
- undefined,
1647
- paneId,
1648
- );
1649
- }
1650
-
1651
- const started = run([...base, "run", paneId, ...deps.viewer?.(identity) ?? defaultViewer(identity)]);
1652
- if (!started.ok) {
1653
- return abandon(`herdr pane run failed: ${firstLine(started.stderr) || "no output"}`, undefined, paneId);
1654
- }
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;
1655
1777
 
1656
- // A worker that has just spawned is working by definition. The ongoing
1657
- // projection of turn/pause/blocked transitions is #842's, from the same typed
1658
- // events — never from the pane's output.
1659
- const reported = reportWorkerPaneState(paneId, label, "working", { run, session });
1660
- if (!reported.ok) {
1661
- return abandon(reported.reason, undefined, paneId);
1662
- }
1663
- 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
+ };
1664
1785
  }
1665
1786
 
1666
1787
  /** Report one lifecycle state for a tracked pane. Monotonic `seq` is the caller's. */
@@ -1762,6 +1883,211 @@ export function retireWorkerPane(
1762
1883
  return closeWorkerPane(paneId, deps);
1763
1884
  }
1764
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
+
1765
2091
  /**
1766
2092
  * Hand back the representation a dead worker left behind (#842), retired for
1767
2093
  * good by closing it (#1035) — but only once the recorded pane is PROVEN still
@@ -2281,6 +2607,37 @@ export function reconcileWorkerPanes(
2281
2607
  });
2282
2608
  }
2283
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
+
2284
2641
  // Every scoped marked workspace whose panes the cleanup above emptied, and
2285
2642
  // that gained no representation this pass, is closed — however many marked
2286
2643
  // surfaces a project accumulated (#1035 review). A live project's primary
package/src/knowledge.ts CHANGED
@@ -19,6 +19,10 @@
19
19
  * accumulated knowledge is worse than one fewer fact, because a worker cannot
20
20
  * tell a clipped warning from a complete one.
21
21
  *
22
+ * What the cap never drops is structure: an operator distillation's headings,
23
+ * paragraphs and blank separators survive every append verbatim and in place —
24
+ * the facts rotate, the shape of the knowledge stays.
25
+ *
22
26
  * Writes are best-effort. This is an optimisation, and a run must never fail
23
27
  * because the overlay could not be written: the callers are settlement paths.
24
28
  */
@@ -74,31 +78,69 @@ function encodeRepoKey(repo: string): string {
74
78
  }
75
79
 
76
80
  /**
77
- * The overlay's entry lines, oldest first, with blank lines and any hand-added
78
- * prose dropped.
81
+ * One line of the overlay document. An `entry` is a fleet-learned fact the
82
+ * `- `-prefixed line the writer produces and attributes; anything else, a
83
+ * heading, a paragraph or a blank separator, is `structure`: hand-added
84
+ * scaffolding from an operator distillation. The distinction is the format:
85
+ * entries are appendable, deduplicated and evictable, structure is none of
86
+ * those things, and it survives every write verbatim and in place.
87
+ */
88
+ type OverlayBlock = { kind: "entry" | "structure"; line: string };
89
+
90
+ /**
91
+ * The overlay document parsed into blocks, in file order. Lossless: joining
92
+ * the lines back with "\n" reproduces the input, which is what makes keeping
93
+ * the structure through a write a property of the parse rather than a hope.
79
94
  *
80
95
  * One parse, shared by the writer and the brief renderer on purpose: they must
81
96
  * agree on what counts as an entry, or the cap one enforces is not the cap the
82
97
  * other renders and a "16 KB" section arrives at 20.
83
98
  */
84
- function entriesOf(text: string): string[] {
85
- return text
86
- .split("\n")
87
- .map((line) => line.trimEnd())
88
- .filter((line) => line.startsWith(ENTRY_PREFIX) && line.slice(ENTRY_PREFIX.length).trim() !== "");
99
+ function blocksOf(text: string): OverlayBlock[] {
100
+ if (text === "") return [];
101
+ const lines = text.split("\n");
102
+ // The final newline is the file's terminator, not a blank last line: pop it
103
+ // here so appended entries join the document itself, and let renderDoc put
104
+ // exactly one back.
105
+ if (lines.at(-1) === "") lines.pop();
106
+ return lines.map((raw) => {
107
+ const line = raw.trimEnd();
108
+ return line.startsWith(ENTRY_PREFIX) && line.slice(ENTRY_PREFIX.length).trim() !== ""
109
+ ? { kind: "entry", line }
110
+ : { kind: "structure", line: raw };
111
+ });
112
+ }
113
+
114
+ /**
115
+ * The document text for a block list: a blank run at the EOF seam is
116
+ * whitespace, not content, so it is trimmed and exactly one newline terminates
117
+ * the document — the shape every writer of this format has always produced.
118
+ * Interior blanks pass through untouched.
119
+ */
120
+ function renderDoc(blocks: readonly OverlayBlock[]): string {
121
+ const kept = [...blocks];
122
+ while (kept.at(-1)?.kind === "structure" && kept.at(-1)?.line.trim() === "") kept.pop();
123
+ return `${kept.map((b) => b.line).join("\n")}\n`;
89
124
  }
90
125
 
91
126
  /**
92
- * The newest entries that fit under {@link KNOWLEDGE_MAX_BYTES}, oldest dropped
93
- * first and only ever whole.
127
+ * The newest content that fits under {@link KNOWLEDGE_MAX_BYTES}, oldest
128
+ * entries dropped first and only ever whole.
129
+ *
130
+ * Only entries evict. A distilled file keeps its headings and paragraphs even
131
+ * when the facts beneath them have been rotated out, and when only non-entry
132
+ * text remains the document simply stays over the ceiling: operator prose is
133
+ * not ours to delete, and distilling it back under the cap is the remedy the
134
+ * format itself names.
94
135
  *
95
136
  * Byte lengths, not character counts: the cap is a context-budget promise, and
96
137
  * one accented identifier or box-drawing character is several bytes.
97
138
  */
98
- function withinCap(entries: readonly string[]): string[] {
99
- const kept = [...entries];
100
- while (kept.length > 0 && Buffer.byteLength(`${kept.join("\n")}\n`, "utf8") > KNOWLEDGE_MAX_BYTES) {
101
- kept.shift();
139
+ function withinCap(blocks: readonly OverlayBlock[]): OverlayBlock[] {
140
+ const kept = [...blocks];
141
+ const bytes = (): number => Buffer.byteLength(renderDoc(kept), "utf8");
142
+ while (kept.some((b) => b.kind === "entry") && bytes() > KNOWLEDGE_MAX_BYTES) {
143
+ kept.splice(kept.findIndex((b) => b.kind === "entry"), 1);
102
144
  }
103
145
  return kept;
104
146
  }
@@ -139,6 +181,10 @@ export function readKnowledge(repo: string): string | undefined {
139
181
  * `proofCommands` for every issue in a repo — cannot inflate the file until the
140
182
  * cap evicts something real.
141
183
  *
184
+ * The rewrite preserves every non-entry line the file carries — the headings,
185
+ * paragraphs and blank separators of an operator distillation — exactly where
186
+ * they were: entries rotate under the cap, structure does not.
187
+ *
142
188
  * Best-effort by contract: the callers are settlement paths, and a failed write
143
189
  * here must never turn a finished run into a failed one.
144
190
  */
@@ -157,25 +203,33 @@ export function appendKnowledge(
157
203
  if (rendered.length === 0) return;
158
204
  const path = knowledgePath(repo);
159
205
  try {
160
- const existing = entriesOf(readKnowledge(repo) ?? "");
161
- // Dynamic membership over the file's own lines, grown as this batch is
206
+ const existing = blocksOf(readKnowledge(repo) ?? "");
207
+ // Dynamic membership over the file's own entries, grown as this batch is
162
208
  // folded in, so one call cannot append the same fact twice either.
163
- const seen = new Set(existing);
164
- const added: string[] = [];
209
+ const seen = new Set(existing.filter((b) => b.kind === "entry").map((b) => b.line));
210
+ const added: OverlayBlock[] = [];
165
211
  for (const line of rendered) {
166
212
  if (seen.has(line)) continue;
167
213
  seen.add(line);
168
- added.push(line);
214
+ added.push({ kind: "entry", line });
169
215
  }
170
216
  if (added.length === 0) return;
171
- const kept = withinCap([...existing, ...added]);
217
+ // A batch appended under a distilled paragraph or heading takes one blank
218
+ // separator, so it never glues onto the prose above it; after an entry or
219
+ // a blank none is needed.
220
+ const tail = existing.at(-1);
221
+ const gap: OverlayBlock[] =
222
+ tail !== undefined && tail.kind === "structure" && tail.line.trim() !== ""
223
+ ? [{ kind: "structure", line: "" }]
224
+ : [];
225
+ const kept = withinCap([...existing, ...gap, ...added]);
172
226
  mkdirSync(dirname(path), { recursive: true });
173
227
  // Atomic like every other durable conductor state file: a brief renderer
174
228
  // reading while a settlement writes sees the old file or the new one, never
175
229
  // a half-written entry.
176
230
  const tmp = `${path}.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`;
177
231
  try {
178
- writeFileSync(tmp, `${kept.join("\n")}\n`, "utf8");
232
+ writeFileSync(tmp, renderDoc(kept), "utf8");
179
233
  renameSync(tmp, path);
180
234
  } catch (err) {
181
235
  rmSync(tmp, { force: true });
@@ -209,7 +263,7 @@ export function appendKnowledge(
209
263
  export function knowledgeSection(repo: string): string {
210
264
  const text = readKnowledge(repo);
211
265
  if (text === undefined) return "";
212
- const entries = withinCap(entriesOf(text));
266
+ const entries = withinCap(blocksOf(text)).filter((b) => b.kind === "entry").map((b) => b.line);
213
267
  if (entries.length === 0) return "";
214
268
  return [
215
269
  KNOWLEDGE_HEADING,