[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
187
infrastructure/ai/sessionExecutionQueue.test.ts
Normal file
187
infrastructure/ai/sessionExecutionQueue.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
chainBySessionKey,
|
||||
getSessionExecutionQueueSizeForTests,
|
||||
reserveSessionSlot,
|
||||
resetSessionExecutionQueueForTests,
|
||||
} from "./shared/sessionExecutionQueue";
|
||||
|
||||
function defer<T = void>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (err: unknown) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (err: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
// Drain pending microtasks and immediates so cleanup `.finally`s have run.
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await new Promise((r) => setImmediate(r));
|
||||
}
|
||||
}
|
||||
|
||||
test("chainBySessionKey serializes calls sharing the same key in dispatch order", async (t) => {
|
||||
t.afterEach(resetSessionExecutionQueueForTests);
|
||||
const events: string[] = [];
|
||||
const gateA = defer();
|
||||
const gateB = defer();
|
||||
const gateC = defer();
|
||||
|
||||
const a = chainBySessionKey("session-1", async () => {
|
||||
events.push("a:start");
|
||||
await gateA.promise;
|
||||
events.push("a:end");
|
||||
return "a";
|
||||
});
|
||||
const b = chainBySessionKey("session-1", async () => {
|
||||
events.push("b:start");
|
||||
await gateB.promise;
|
||||
events.push("b:end");
|
||||
return "b";
|
||||
});
|
||||
const c = chainBySessionKey("session-1", async () => {
|
||||
events.push("c:start");
|
||||
await gateC.promise;
|
||||
events.push("c:end");
|
||||
return "c";
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(events, ["a:start"]);
|
||||
|
||||
gateA.resolve();
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(events, ["a:start", "a:end", "b:start"]);
|
||||
|
||||
gateB.resolve();
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(events, ["a:start", "a:end", "b:start", "b:end", "c:start"]);
|
||||
|
||||
gateC.resolve();
|
||||
assert.equal(await a, "a");
|
||||
assert.equal(await b, "b");
|
||||
assert.equal(await c, "c");
|
||||
});
|
||||
|
||||
test("chainBySessionKey does not let one task's rejection poison the next", async (t) => {
|
||||
t.afterEach(resetSessionExecutionQueueForTests);
|
||||
const a = chainBySessionKey("session-2", async () => {
|
||||
throw new Error("a boom");
|
||||
});
|
||||
const b = chainBySessionKey("session-2", async () => "b ok");
|
||||
|
||||
await assert.rejects(a, /a boom/);
|
||||
assert.equal(await b, "b ok");
|
||||
});
|
||||
|
||||
test("chainBySessionKey isolates work across keys (different sessions run in parallel)", async (t) => {
|
||||
t.afterEach(resetSessionExecutionQueueForTests);
|
||||
const gateA = defer();
|
||||
const gateB = defer();
|
||||
|
||||
const a = chainBySessionKey("session-a", async () => {
|
||||
await gateA.promise;
|
||||
return "a";
|
||||
});
|
||||
const b = chainBySessionKey("session-b", async () => {
|
||||
await gateB.promise;
|
||||
return "b";
|
||||
});
|
||||
|
||||
// Resolve B first; it should finish even though A is still blocked.
|
||||
gateB.resolve();
|
||||
assert.equal(await b, "b");
|
||||
gateA.resolve();
|
||||
assert.equal(await a, "a");
|
||||
});
|
||||
|
||||
test("queue Map drops the entry once the last task drains", async (t) => {
|
||||
t.afterEach(resetSessionExecutionQueueForTests);
|
||||
resetSessionExecutionQueueForTests();
|
||||
assert.equal(getSessionExecutionQueueSizeForTests(), 0);
|
||||
await chainBySessionKey("session-cleanup", async () => "done");
|
||||
await flushMicrotasks();
|
||||
// Without the cleanup `finally`, the resolved tail would stay parked
|
||||
// in the map; this is the regression guard that the previous version
|
||||
// of this test was missing.
|
||||
assert.equal(getSessionExecutionQueueSizeForTests(), 0);
|
||||
});
|
||||
|
||||
test("reserveSessionSlot fixes order at reservation time regardless of pre-work duration", async (t) => {
|
||||
t.afterEach(resetSessionExecutionQueueForTests);
|
||||
const events: string[] = [];
|
||||
|
||||
// Simulate three tool_use calls firing in parallel where each has
|
||||
// some pre-work (e.g. an approval prompt) of varying length. The
|
||||
// serialized work order must still match the slot-reservation order.
|
||||
async function run(name: string, prework: Promise<void>) {
|
||||
const slot = reserveSessionSlot("session-order");
|
||||
try {
|
||||
events.push(`${name}:reserved`);
|
||||
await prework;
|
||||
events.push(`${name}:prework-done`);
|
||||
await slot.ready;
|
||||
events.push(`${name}:serial-start`);
|
||||
// Trivial serial work, just so we can observe the start order.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
events.push(`${name}:serial-end`);
|
||||
return name;
|
||||
} finally {
|
||||
slot.release();
|
||||
}
|
||||
}
|
||||
|
||||
const gateA = defer();
|
||||
const gateB = defer();
|
||||
const gateC = defer();
|
||||
const a = run("A", gateA.promise);
|
||||
const b = run("B", gateB.promise);
|
||||
const c = run("C", gateC.promise);
|
||||
|
||||
// Approvals land in reverse order — C first, then B, then A.
|
||||
await flushMicrotasks();
|
||||
gateC.resolve();
|
||||
await flushMicrotasks();
|
||||
gateB.resolve();
|
||||
await flushMicrotasks();
|
||||
gateA.resolve();
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(await Promise.all([a, b, c]), ["A", "B", "C"]);
|
||||
|
||||
const serialStarts = events.filter((e) => e.endsWith(":serial-start"));
|
||||
assert.deepEqual(
|
||||
serialStarts,
|
||||
["A:serial-start", "B:serial-start", "C:serial-start"],
|
||||
`serial work ran in reservation order, not approval order; got: ${events.join(", ")}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("reserveSessionSlot lets later slots proceed once an early slot releases without serialized work", async (t) => {
|
||||
t.afterEach(resetSessionExecutionQueueForTests);
|
||||
const events: string[] = [];
|
||||
|
||||
async function maybeRun(name: string, run: boolean) {
|
||||
const slot = reserveSessionSlot("session-skip");
|
||||
try {
|
||||
events.push(`${name}:reserved`);
|
||||
if (!run) return `${name}:skipped`;
|
||||
await slot.ready;
|
||||
events.push(`${name}:ran`);
|
||||
return `${name}:ran`;
|
||||
} finally {
|
||||
slot.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Slot A reserves but skips serial work (mimics user denying approval
|
||||
// or aborting). Slot B should still proceed.
|
||||
const a = maybeRun("A", false);
|
||||
const b = maybeRun("B", true);
|
||||
assert.equal(await a, "A:skipped");
|
||||
assert.equal(await b, "B:ran");
|
||||
});
|
||||
Reference in New Issue
Block a user