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/package.json +1 -1
- package/schema/config.schema.json +24 -0
- package/src/commands/companion.ts +52 -16
- package/src/commands/daemon.ts +14 -25
- package/src/commands/drain.ts +12 -5
- package/src/commands/worker.ts +5 -0
- package/src/config-schema.ts +8 -0
- package/src/config.ts +17 -2
- package/src/daemon/drain.ts +33 -0
- package/src/daemon/http.ts +28 -0
- package/src/daemon/review.ts +11 -11
- package/src/daemon/supervision.ts +144 -1
- package/src/daemon/tick.ts +46 -15
- package/src/daemon/views.ts +17 -0
- package/src/daemon.ts +2 -1
- package/src/decisions.ts +20 -9
- package/src/diff-flags.ts +186 -31
- package/src/doctor.ts +320 -9
- package/src/escalate.ts +187 -15
- package/src/failure-class.ts +176 -4
- package/src/fleet.ts +444 -50
- package/src/graph-health.ts +17 -1
- package/src/groom.ts +11 -0
- package/src/knowledge.ts +75 -21
- package/src/lifecycle.ts +267 -5
- package/src/orchestrator-tick.ts +8 -2
- package/src/ready-gate.ts +100 -5
- package/src/reports.ts +4 -1
- package/src/settlement.ts +98 -7
- package/src/status-render.ts +27 -2
- package/src/store.ts +50 -13
- package/src/to-spec.ts +52 -0
- package/src/tracker/github.ts +132 -10
- package/src/types.ts +31 -0
- package/src/upgrade.ts +12 -5
- package/src/verbs/server.ts +16 -12
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
1614
|
-
if (!
|
|
1615
|
-
return abandon(
|
|
1616
|
-
}
|
|
1617
|
-
|
|
1618
|
-
//
|
|
1619
|
-
//
|
|
1620
|
-
//
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
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
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|