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/package.json +1 -1
- package/src/commands/daemon.ts +14 -25
- package/src/daemon/tick.ts +15 -1
- package/src/diff-flags.ts +93 -9
- package/src/doctor.ts +295 -2
- package/src/fleet.ts +402 -45
- package/src/knowledge.ts +75 -21
- package/src/lifecycle.ts +267 -5
- package/src/ready-gate.ts +100 -5
- package/src/upgrade.ts +12 -5
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
1622
|
-
if (!
|
|
1623
|
-
return abandon(
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
|
-
//
|
|
1627
|
-
//
|
|
1628
|
-
//
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
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
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
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
|
-
*
|
|
78
|
-
*
|
|
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
|
|
85
|
-
return
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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(
|
|
99
|
-
const kept = [...
|
|
100
|
-
|
|
101
|
-
|
|
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 =
|
|
161
|
-
// Dynamic membership over the file's own
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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,
|