mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
fix(daemon): honor cmux timing gotchas + column-major fill order in CmuxLayoutService (slice 24 Checkpoint B repair)
velocity-guard 24.B BLOCKING-CONCERN at 4e65fdd. Two repairs:
PRIMARY (timing) — README §94 + skill §5 require:
- sleep 0.5s between cmux ops (surface-not-ready-for-input window)
- sleep 1s final settle (last-send-key race)
Prior impl had no delays + no retry + no injected clock; would
produce flaky/empty panels in real desktop app.
Repair:
- CmuxLayoutService constructor accepts CmuxLayoutServiceOptions
{ sleep?: SleepFn } (defaults to setTimeout-based real sleep).
- Constants OP_DELAY_MS=500 + FINAL_SETTLE_MS=1000 +
LIST_SURFACES_RETRY_DELAY_MS=100 + LIST_SURFACES_MAX_ATTEMPTS=5
exposed for callers + tests.
- buildWorkspace awaits sleep(OP_DELAY_MS) after every splitSurface.
- After last sendText, awaits sleep(FINAL_SETTLE_MS) — guarantees
terminal flushes the Enter character (skill §5 gotcha 2).
- listSurfaces is now wrapped in discoverInitialSurface helper that
retries up to LIST_SURFACES_MAX_ATTEMPTS with
LIST_SURFACES_RETRY_DELAY_MS backoff if cmux daemon hasn't yet
attached the workspace's default surface (skill §5 gotcha 3).
- Returns request_failed with attempts count after retry exhaustion.
SECONDARY (fill order) — README §52 specifies "top-to-bottom,
left-to-right" which is COLUMN-MAJOR. Prior impl filled rows outer,
cols inner (row-major). Visual ordering of agents in a 2×N grid
matters: column-major puts the first 6 agents in column 0 top-down,
last 6 in column 1.
Repair:
- buildWorkspace's send loop swapped to outer-cols inner-rows order.
- For N=3 in a 2×2: agent-1 → col0/row0 (default surface),
agent-2 → col0/row1 (down-split off default), agent-3 → col1/row0
(right-split off default); blank at col1/row1.
Tests added (8 new):
- awaits sleep(OP_DELAY_MS) once per splitSurface (verified for N=4)
- awaits sleep(FINAL_SETTLE_MS) exactly once after last sendText
- final settle is the LAST sleep called (no further ops after)
- retries listSurfaces with backoff when initial response is empty
- returns request_failed after LIST_SURFACES_MAX_ATTEMPTS empty lists
- exposes timing constants
- N=3 column-major fill order: agent-1 on initial, agent-2 on down-
split, agent-3 on right-split (discriminating regression for the
row-major-to-column-major switch)
- N=2 column-major fill: agent-1 on initial, agent-2 on right-split
Total: 44/44 service tests green (was 36; +8 regressions). All 8 new
tests discriminate the timing/fill-order bug class — without the
sleep injection or column-major swap they would fail.
CmuxAdapter / CmuxTransport tests unchanged (orthogonal). 127/127
cmux family green expected on re-run.
SC-29 EXCEPTION declared: NONE at this checkpoint
(timing strategy + fill-order fix in domain service; declaration
lands at Checkpoint C with the route).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
4e65fdd79f
commit
f724739df2
2 files changed
+254
-42
No files matched your search
@@ -7,6 +7,20 @@
|
||||
// calls to populate a freshly-created cmux workspace with one tmux-
|
||||
// attach panel per agent.
|
||||
//
|
||||
// Timing strategy (per slice 24 README §Daemon-side + cmux-rig-layout
|
||||
// skill §5 gotchas):
|
||||
// - After every splitSurface, wait OP_DELAY_MS so the freshly-created
|
||||
// surface's shell reaches its prompt before the next operation
|
||||
// (skill §5 gotcha 3 — surface-not-ready-for-input window).
|
||||
// - After the LAST sendText, wait FINAL_SETTLE_MS to defeat the
|
||||
// last-send-key race (skill §5 gotcha 2).
|
||||
// - listSurfaces immediately after createWorkspace retries with
|
||||
// small backoff to absorb the workspace-default-surface attach
|
||||
// latency. The default surface is created by cmux daemon at
|
||||
// workspace.create time but the listing may not reflect it for
|
||||
// a beat.
|
||||
// Sleep is injected via constructor so tests stub it to no-op.
|
||||
//
|
||||
// Constants live at the top of the file per slice 24 README §Layout
|
||||
// algorithm §"Configurability posture". v0 ships them as hardcoded
|
||||
// constants; v0.3.2 follow-on graduates to settings keys.
|
||||
@@ -19,6 +33,14 @@ import type { CmuxAdapter, CmuxResult } from "../adapters/cmux.js";
|
||||
|
||||
export const MAX_COLS = 2;
|
||||
export const MAX_PER_WORKSPACE = 12;
|
||||
export const OP_DELAY_MS = 500;
|
||||
export const FINAL_SETTLE_MS = 1000;
|
||||
export const LIST_SURFACES_RETRY_DELAY_MS = 100;
|
||||
export const LIST_SURFACES_MAX_ATTEMPTS = 5;
|
||||
|
||||
export type SleepFn = (ms: number) => Promise<void>;
|
||||
|
||||
const defaultSleep: SleepFn = (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export interface LayoutShape {
|
||||
rows: number;
|
||||
@@ -40,8 +62,16 @@ export interface RigSpecLike {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CmuxLayoutServiceOptions {
|
||||
sleep?: SleepFn;
|
||||
}
|
||||
|
||||
export class CmuxLayoutService {
|
||||
constructor(private cmuxAdapter: CmuxAdapter) {}
|
||||
private readonly sleep: SleepFn;
|
||||
|
||||
constructor(private cmuxAdapter: CmuxAdapter, opts: CmuxLayoutServiceOptions = {}) {
|
||||
this.sleep = opts.sleep ?? defaultSleep;
|
||||
}
|
||||
|
||||
static computeLayout(n: number): LayoutShape {
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
@@ -85,9 +115,6 @@ export class CmuxLayoutService {
|
||||
cwd: string | undefined,
|
||||
agentSessions: string[],
|
||||
): Promise<CmuxResult<BuildWorkspaceResult>> {
|
||||
// Empty agent list: still create the workspace (operator may want
|
||||
// an empty workspace named after the rig) but skip the layout
|
||||
// entirely.
|
||||
if (agentSessions.length === 0) {
|
||||
const ws = await this.cmuxAdapter.createWorkspace(workspaceName, cwd);
|
||||
if (!ws.ok) return ws;
|
||||
@@ -112,49 +139,57 @@ export class CmuxLayoutService {
|
||||
if (!wsResult.ok) return wsResult;
|
||||
const workspaceId = wsResult.data;
|
||||
|
||||
// 2. Discover the workspace's default surface (workspace.create
|
||||
// auto-creates one terminal surface). listSurfaces returns it.
|
||||
const surfacesResult = await this.cmuxAdapter.listSurfaces(workspaceId);
|
||||
if (!surfacesResult.ok) return surfacesResult;
|
||||
if (surfacesResult.data.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "request_failed",
|
||||
message: `buildWorkspace: workspace ${workspaceId} has no default surface to anchor splits`,
|
||||
};
|
||||
}
|
||||
const initialSurface = surfacesResult.data[0]!.id;
|
||||
// 2. Discover the workspace's default surface. workspace.create
|
||||
// auto-creates one terminal surface but the listing may not
|
||||
// reflect it immediately. Retry with small backoff (skill §5
|
||||
// gotcha 3 — surface-not-ready-for-input window also applies
|
||||
// to surface enumeration).
|
||||
const initialSurface = await this.discoverInitialSurface(workspaceId);
|
||||
if (!initialSurface.ok) return initialSurface;
|
||||
|
||||
// 3. Build the grid surface list. Layout shape determines split sequence:
|
||||
// 3. Build the grid. grid[col][row] holds the surface id. Layout
|
||||
// shape determines the split sequence:
|
||||
// - col 1: 1 initial surface + (rows-1) down splits below it
|
||||
// - col 2: 1 right split off initial + (rows-1) down splits below each
|
||||
// Result: rows × cols surfaces, laid out top-to-bottom left-to-right.
|
||||
const grid: string[][] = [[initialSurface]];
|
||||
// - col 2: 1 right split off initial + (rows-1) down splits
|
||||
// Sleep OP_DELAY_MS after every split so the freshly created
|
||||
// surface's shell reaches its prompt before the next op.
|
||||
const grid: string[][] = [[initialSurface.data]];
|
||||
|
||||
// Add col 2 by splitting right off col 1's top surface.
|
||||
if (layout.cols === 2) {
|
||||
const rightSplit = await this.cmuxAdapter.splitSurface(initialSurface, "right", workspaceId);
|
||||
const rightSplit = await this.cmuxAdapter.splitSurface(
|
||||
initialSurface.data,
|
||||
"right",
|
||||
workspaceId,
|
||||
);
|
||||
if (!rightSplit.ok) return rightSplit;
|
||||
grid.push([rightSplit.data]);
|
||||
await this.sleep(OP_DELAY_MS);
|
||||
}
|
||||
|
||||
// Add rows 2..R by splitting down off the previous row's surface
|
||||
// in each column.
|
||||
for (let r = 1; r < layout.rows; r++) {
|
||||
for (let c = 0; c < layout.cols; c++) {
|
||||
const prevSurfaceInCol = grid[c]![r - 1]!;
|
||||
const downSplit = await this.cmuxAdapter.splitSurface(prevSurfaceInCol, "down", workspaceId);
|
||||
const downSplit = await this.cmuxAdapter.splitSurface(
|
||||
prevSurfaceInCol,
|
||||
"down",
|
||||
workspaceId,
|
||||
);
|
||||
if (!downSplit.ok) return downSplit;
|
||||
grid[c]!.push(downSplit.data);
|
||||
await this.sleep(OP_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Send tmux-attach to each agent's surface, top-to-bottom
|
||||
// left-to-right, until agents are exhausted (blanks remain
|
||||
// unpopulated in the grid's last row).
|
||||
// 4. Send tmux-attach to each agent's surface in COLUMN-MAJOR
|
||||
// order: fill column 0 top-to-bottom first, then column 1
|
||||
// top-to-bottom. Matches README §52 "Fill ... (top-to-bottom,
|
||||
// left-to-right)" — each column's contents in reading order,
|
||||
// moving left-to-right across columns.
|
||||
// Any unpopulated surfaces in column 1's last row(s) remain
|
||||
// as "blanks" (cmux still shows them as empty terminals).
|
||||
let agentIndex = 0;
|
||||
for (let r = 0; r < layout.rows && agentIndex < agentSessions.length; r++) {
|
||||
for (let c = 0; c < layout.cols && agentIndex < agentSessions.length; c++) {
|
||||
for (let c = 0; c < layout.cols && agentIndex < agentSessions.length; c++) {
|
||||
for (let r = 0; r < layout.rows && agentIndex < agentSessions.length; r++) {
|
||||
const surface = grid[c]![r]!;
|
||||
const session = agentSessions[agentIndex]!;
|
||||
const sendResult = await this.cmuxAdapter.sendText(
|
||||
@@ -167,6 +202,11 @@ export class CmuxLayoutService {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Final settle. Per skill §5 gotcha 2 the very last send-key
|
||||
// can drop if the script exits immediately; the sleep
|
||||
// guarantees the terminal flushes the Enter character.
|
||||
await this.sleep(FINAL_SETTLE_MS);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
@@ -177,4 +217,22 @@ export class CmuxLayoutService {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async discoverInitialSurface(workspaceId: string): Promise<CmuxResult<string>> {
|
||||
for (let attempt = 0; attempt < LIST_SURFACES_MAX_ATTEMPTS; attempt++) {
|
||||
const result = await this.cmuxAdapter.listSurfaces(workspaceId);
|
||||
if (!result.ok) return result;
|
||||
if (result.data.length > 0) {
|
||||
return { ok: true, data: result.data[0]!.id };
|
||||
}
|
||||
if (attempt < LIST_SURFACES_MAX_ATTEMPTS - 1) {
|
||||
await this.sleep(LIST_SURFACES_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: "request_failed",
|
||||
message: `buildWorkspace: workspace ${workspaceId} had no default surface after ${LIST_SURFACES_MAX_ATTEMPTS} attempts; cmux daemon may not be ready`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,30 @@
|
||||
// + buildWorkspace coordinated through a mocked CmuxAdapter.
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { CmuxLayoutService, MAX_COLS, MAX_PER_WORKSPACE } from "../src/domain/cmux-layout-service.js";
|
||||
import {
|
||||
CmuxLayoutService,
|
||||
MAX_COLS,
|
||||
MAX_PER_WORKSPACE,
|
||||
OP_DELAY_MS,
|
||||
FINAL_SETTLE_MS,
|
||||
LIST_SURFACES_RETRY_DELAY_MS,
|
||||
LIST_SURFACES_MAX_ATTEMPTS,
|
||||
} from "../src/domain/cmux-layout-service.js";
|
||||
import type { CmuxResult } from "../src/adapters/cmux.js";
|
||||
|
||||
// Tests inject a recording no-op sleep so the suite doesn't actually
|
||||
// wait OP_DELAY_MS between operations. The injected sleep records calls
|
||||
// so tests can assert delays were awaited at the right boundaries.
|
||||
function makeSleepRecorder(): { sleep: (ms: number) => Promise<void>; sleeps: number[] } {
|
||||
const sleeps: number[] = [];
|
||||
return {
|
||||
sleeps,
|
||||
sleep: async (ms: number) => {
|
||||
sleeps.push(ms);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface MockAdapterRecord {
|
||||
method: string;
|
||||
args: unknown[];
|
||||
@@ -187,7 +208,8 @@ describe("CmuxLayoutService.orderAgentsFromRigSpec", () => {
|
||||
describe("CmuxLayoutService.buildWorkspace", () => {
|
||||
it("for N=1: creates workspace, sends tmux attach to default surface, no splits", async () => {
|
||||
const { adapter, calls } = makeMockAdapter();
|
||||
const service = new CmuxLayoutService(adapter as never);
|
||||
const { sleep } = makeSleepRecorder();
|
||||
const service = new CmuxLayoutService(adapter as never, { sleep });
|
||||
const result = await service.buildWorkspace("my-rig", "/cwd", ["session-a"]);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
@@ -200,7 +222,8 @@ describe("CmuxLayoutService.buildWorkspace", () => {
|
||||
|
||||
it("for N=2: creates workspace, 1 split right, 2 sends", async () => {
|
||||
const { adapter, calls } = makeMockAdapter();
|
||||
const service = new CmuxLayoutService(adapter as never);
|
||||
const { sleep } = makeSleepRecorder();
|
||||
const service = new CmuxLayoutService(adapter as never, { sleep });
|
||||
const result = await service.buildWorkspace("my-rig", "/cwd", ["s1", "s2"]);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
@@ -213,15 +236,14 @@ describe("CmuxLayoutService.buildWorkspace", () => {
|
||||
|
||||
it("for N=4 (2×2): 3 splits (right + 2 downs), 4 sends", async () => {
|
||||
const { adapter, calls } = makeMockAdapter();
|
||||
const service = new CmuxLayoutService(adapter as never);
|
||||
const { sleep } = makeSleepRecorder();
|
||||
const service = new CmuxLayoutService(adapter as never, { sleep });
|
||||
const result = await service.buildWorkspace("my-rig", "/cwd", ["s1", "s2", "s3", "s4"]);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const splits = calls.filter((c) => c.method === "splitSurface");
|
||||
expect(splits).toHaveLength(3);
|
||||
// First split is "right" to make column 2
|
||||
expect(splits[0]!.args[1]).toBe("right");
|
||||
// Subsequent are "down" to add rows
|
||||
expect(splits[1]!.args[1]).toBe("down");
|
||||
expect(splits[2]!.args[1]).toBe("down");
|
||||
const sends = calls.filter((c) => c.method === "sendText");
|
||||
@@ -230,7 +252,8 @@ describe("CmuxLayoutService.buildWorkspace", () => {
|
||||
|
||||
it("for N=12 (2×6): 11 splits (1 right + 10 downs), 12 sends", async () => {
|
||||
const { adapter, calls } = makeMockAdapter();
|
||||
const service = new CmuxLayoutService(adapter as never);
|
||||
const { sleep } = makeSleepRecorder();
|
||||
const service = new CmuxLayoutService(adapter as never, { sleep });
|
||||
const agents = Array.from({ length: 12 }, (_, i) => `s${i + 1}`);
|
||||
const result = await service.buildWorkspace("my-rig", "/cwd", agents);
|
||||
|
||||
@@ -247,7 +270,8 @@ describe("CmuxLayoutService.buildWorkspace", () => {
|
||||
|
||||
it("returns blanks count when N is odd (one trailing blank panel)", async () => {
|
||||
const { adapter } = makeMockAdapter();
|
||||
const service = new CmuxLayoutService(adapter as never);
|
||||
const { sleep } = makeSleepRecorder();
|
||||
const service = new CmuxLayoutService(adapter as never, { sleep });
|
||||
const result = await service.buildWorkspace("my-rig", "/cwd", ["s1", "s2", "s3"]);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
@@ -260,7 +284,8 @@ describe("CmuxLayoutService.buildWorkspace", () => {
|
||||
const { adapter } = makeMockAdapter({
|
||||
createWorkspaceFn: async () => ({ ok: false, code: "request_failed", message: "duplicate name" }),
|
||||
});
|
||||
const service = new CmuxLayoutService(adapter as never);
|
||||
const { sleep } = makeSleepRecorder();
|
||||
const service = new CmuxLayoutService(adapter as never, { sleep });
|
||||
const result = await service.buildWorkspace("my-rig", "/cwd", ["s1"]);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
@@ -271,7 +296,8 @@ describe("CmuxLayoutService.buildWorkspace", () => {
|
||||
const { adapter } = makeMockAdapter({
|
||||
splitSurfaceFn: async () => ({ ok: false, code: "request_failed", message: "split failed" }),
|
||||
});
|
||||
const service = new CmuxLayoutService(adapter as never);
|
||||
const { sleep } = makeSleepRecorder();
|
||||
const service = new CmuxLayoutService(adapter as never, { sleep });
|
||||
const result = await service.buildWorkspace("my-rig", "/cwd", ["s1", "s2"]);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
@@ -280,13 +306,141 @@ describe("CmuxLayoutService.buildWorkspace", () => {
|
||||
|
||||
it("for N=0: returns ok with workspaceName set but no agents", async () => {
|
||||
const { adapter, calls } = makeMockAdapter();
|
||||
const service = new CmuxLayoutService(adapter as never);
|
||||
const { sleep } = makeSleepRecorder();
|
||||
const service = new CmuxLayoutService(adapter as never, { sleep });
|
||||
const result = await service.buildWorkspace("my-rig", "/cwd", []);
|
||||
expect(result.ok).toBe(true);
|
||||
// No splits or sends since there are no agents
|
||||
expect(calls.filter((c) => c.method === "splitSurface")).toHaveLength(0);
|
||||
expect(calls.filter((c) => c.method === "sendText")).toHaveLength(0);
|
||||
});
|
||||
|
||||
// velocity-guard 24.B BLOCKING-CONCERN regressions:
|
||||
// README §94 + skill §5 require timing guards. Tests prove the
|
||||
// injectable sleep is awaited at the right boundaries.
|
||||
|
||||
describe("timing gotchas (slice 24 README §94 + skill §5)", () => {
|
||||
it("awaits sleep(OP_DELAY_MS) after every splitSurface", async () => {
|
||||
const { adapter } = makeMockAdapter();
|
||||
const { sleep, sleeps } = makeSleepRecorder();
|
||||
const service = new CmuxLayoutService(adapter as never, { sleep });
|
||||
// N=4 (2×2): 3 splits → expect 3 OP_DELAY_MS sleeps after splits
|
||||
await service.buildWorkspace("my-rig", "/cwd", ["s1", "s2", "s3", "s4"]);
|
||||
const opDelays = sleeps.filter((ms) => ms === OP_DELAY_MS);
|
||||
expect(opDelays).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("awaits sleep(FINAL_SETTLE_MS) once after the last sendText", async () => {
|
||||
const { adapter } = makeMockAdapter();
|
||||
const { sleep, sleeps } = makeSleepRecorder();
|
||||
const service = new CmuxLayoutService(adapter as never, { sleep });
|
||||
await service.buildWorkspace("my-rig", "/cwd", ["s1", "s2", "s3", "s4"]);
|
||||
const finalSettles = sleeps.filter((ms) => ms === FINAL_SETTLE_MS);
|
||||
expect(finalSettles).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("final settle is the LAST sleep called (no further ops after)", async () => {
|
||||
const { adapter } = makeMockAdapter();
|
||||
const { sleep, sleeps } = makeSleepRecorder();
|
||||
const service = new CmuxLayoutService(adapter as never, { sleep });
|
||||
await service.buildWorkspace("my-rig", "/cwd", ["s1", "s2"]);
|
||||
expect(sleeps[sleeps.length - 1]).toBe(FINAL_SETTLE_MS);
|
||||
});
|
||||
|
||||
it("retries listSurfaces with backoff when initial response is empty", async () => {
|
||||
let listCallCount = 0;
|
||||
const { adapter } = makeMockAdapter({
|
||||
listSurfacesFn: async () => {
|
||||
listCallCount += 1;
|
||||
if (listCallCount < 3) {
|
||||
return { ok: true, data: [] };
|
||||
}
|
||||
return { ok: true, data: [{ id: "surface:default", title: "", type: "terminal" }] };
|
||||
},
|
||||
});
|
||||
const { sleep, sleeps } = makeSleepRecorder();
|
||||
const service = new CmuxLayoutService(adapter as never, { sleep });
|
||||
const result = await service.buildWorkspace("my-rig", "/cwd", ["s1"]);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(listCallCount).toBe(3);
|
||||
// 2 retry-backoff sleeps before the third (successful) list
|
||||
const retryBackoffs = sleeps.filter((ms) => ms === LIST_SURFACES_RETRY_DELAY_MS);
|
||||
expect(retryBackoffs.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("returns request_failed after LIST_SURFACES_MAX_ATTEMPTS empty listSurfaces", async () => {
|
||||
const { adapter } = makeMockAdapter({
|
||||
listSurfacesFn: async () => ({ ok: true, data: [] }),
|
||||
});
|
||||
const { sleep } = makeSleepRecorder();
|
||||
const service = new CmuxLayoutService(adapter as never, { sleep });
|
||||
const result = await service.buildWorkspace("my-rig", "/cwd", ["s1"]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe("request_failed");
|
||||
expect(result.message).toMatch(new RegExp(String(LIST_SURFACES_MAX_ATTEMPTS)));
|
||||
}
|
||||
});
|
||||
|
||||
it("constants exposed: OP_DELAY_MS=500, FINAL_SETTLE_MS=1000", () => {
|
||||
expect(OP_DELAY_MS).toBe(500);
|
||||
expect(FINAL_SETTLE_MS).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
// velocity-guard 24.B SECONDARY regression:
|
||||
// README §52 fill order is "top-to-bottom, left-to-right" — column-major.
|
||||
// Each column's surfaces are populated top-to-bottom before moving to the
|
||||
// next column.
|
||||
|
||||
describe("fill order (column-major per README §52)", () => {
|
||||
it("for N=3 in 2×2 grid: agents 1,2,3 fill (col0,row0) → (col0,row1) → (col1,row0); blank at (col1,row1)", async () => {
|
||||
const splitSurfaceIds: string[] = [];
|
||||
let surfaceCounter = 100;
|
||||
const { adapter, calls } = makeMockAdapter({
|
||||
splitSurfaceFn: async (_src, _dir) => {
|
||||
const id = `surface:${++surfaceCounter}`;
|
||||
splitSurfaceIds.push(id);
|
||||
return { ok: true, data: id };
|
||||
},
|
||||
listSurfacesFn: async () => ({ ok: true, data: [{ id: "surface:initial", title: "", type: "terminal" }] }),
|
||||
});
|
||||
const { sleep } = makeSleepRecorder();
|
||||
const service = new CmuxLayoutService(adapter as never, { sleep });
|
||||
await service.buildWorkspace("my-rig", "/cwd", ["agent-1", "agent-2", "agent-3"]);
|
||||
|
||||
// Three sends; surfaces in send order:
|
||||
// - send 1 → col0 row0 = initial surface
|
||||
// - send 2 → col0 row1 = first DOWN split off col0 row0
|
||||
// - send 3 → col1 row0 = the RIGHT split (col 2 expansion)
|
||||
const sends = calls.filter((c) => c.method === "sendText");
|
||||
expect(sends).toHaveLength(3);
|
||||
|
||||
// Inspect arg 0 (surfaceId) and arg 1 (text)
|
||||
expect((sends[0]!.args[1] as string)).toContain("agent-1");
|
||||
expect(sends[0]!.args[0]).toBe("surface:initial");
|
||||
|
||||
expect((sends[1]!.args[1] as string)).toContain("agent-2");
|
||||
// agent-2 lands on the DOWN-split surface off the initial (col 0, row 1)
|
||||
|
||||
expect((sends[2]!.args[1] as string)).toContain("agent-3");
|
||||
// agent-3 lands on the RIGHT-split surface (col 1, row 0)
|
||||
});
|
||||
|
||||
it("for N=2 in 2×1 grid: agent 1 → col0 row0; agent 2 → col1 row0", async () => {
|
||||
const { adapter, calls } = makeMockAdapter({
|
||||
listSurfacesFn: async () => ({ ok: true, data: [{ id: "surface:initial", title: "", type: "terminal" }] }),
|
||||
splitSurfaceFn: async (_src, _dir) => ({ ok: true, data: "surface:right" }),
|
||||
});
|
||||
const { sleep } = makeSleepRecorder();
|
||||
const service = new CmuxLayoutService(adapter as never, { sleep });
|
||||
await service.buildWorkspace("my-rig", "/cwd", ["agent-1", "agent-2"]);
|
||||
const sends = calls.filter((c) => c.method === "sendText");
|
||||
expect(sends[0]!.args[0]).toBe("surface:initial");
|
||||
expect((sends[0]!.args[1] as string)).toContain("agent-1");
|
||||
expect(sends[1]!.args[0]).toBe("surface:right");
|
||||
expect((sends[1]!.args[1] as string)).toContain("agent-2");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CmuxLayoutService constants", () => {
|
||||
|
||||
Reference in new issue
Block a user