feat(queue): server-side PTY-inject + idle-drained follow-up queue (W2)

The walk-away primitive: queue follow-up prompts and let a session advance itself
while you're gone — "run the tests" drains, and when Claude next goes idle "open a
PR" drains. Injection reuses writeInput (byte-identical to a keystroke, broadcasts
to all mirrored devices); the byte-shuttle is untouched.

- POST/GET/DELETE /live-sessions/:id/queue — POST/DELETE behind requireAllowedOrigin
  + a per-IP rate limit (QUEUE_RATE_MAX=20/min) + SESSION_ID_RE; text non-empty,
  byte-capped (QUEUE_ITEM_MAX_BYTES=4096, 16kb body), count-capped (QUEUE_MAX_ITEMS=10).
- Bounded per-session queue in manager (enqueueFollowup/drainOne/clearQueue);
  new optional queueLength on LiveSessionInfo.
- Idle drain: the Stop/SessionEnd /hook branch scheduleDrain()s a debounced timer
  (QUEUE_SETTLE_MS=1500). It drains exactly one entry only if the session still
  exists, hasn't exited, produced no output during settle, and is idle — three
  guards (debounced single timer + pop-one + settle re-check) → one entry per idle.
- New additive ServerMessage {type:'queue',length} broadcasts the count to mirrors
  (⧗N badge); FE enqueue via the quick-reply editor + TabApp.enqueueToActive.

Injected bytes go verbatim to the PTY (never built into a shell command). Verified
independently: typecheck + build:web clean, 1737 tests pass (real-PTY integration
covers idle-drain / one-per-idle / settle-guard). Foundation for templated launches,
auto-continue, and issue-intake (docs/ROADMAP.md).
This commit is contained in:
Yaojia Wang
2026-07-12 20:27:37 +02:00
parent e062065cd3
commit 3076843e9c
16 changed files with 1164 additions and 10 deletions

View File

@@ -88,6 +88,11 @@ const CFG: Config = {
worktreeTimeoutMs: 10_000,
defaultPermissionMode: 'default',
allowAutoMode: false,
// W2 inject queue
queueEnabled: true,
queueMaxItems: 10,
queueItemMaxBytes: 4096,
queueSettleMs: 1500,
};
const DIMS: Dims = { cols: 80, rows: 24 };
@@ -1145,3 +1150,137 @@ describe('list — lastOutputAt field (T-iOS-37)', () => {
});
});
});
// ── W2: inject-queue methods (enqueueFollowup / drainOne / clearQueue) ────────
describe('W2 inject queue — enqueueFollowup', () => {
it('appends and returns {ok,length}, broadcasting {type:queue,length} to clients', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
ws.sent.length = 0;
const r1 = mgr.enqueueFollowup(s.meta.id, 'echo one\r');
expect(r1).toEqual({ ok: true, length: 1 });
expect(s.queue).toEqual(['echo one\r']);
expect(parseSent(ws).some((m) => m.type === 'queue' && m.length === 1)).toBe(true);
const r2 = mgr.enqueueFollowup(s.meta.id, 'echo two\r');
expect(r2).toEqual({ ok: true, length: 2 });
expect(s.queue).toEqual(['echo one\r', 'echo two\r']);
});
it('caps at queueMaxItems → {ok:false,reason:full}, no broadcast, queue unchanged', () => {
const mgr = createSessionManager({ ...CFG, queueMaxItems: 2 });
nextPty = createMockPty();
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.enqueueFollowup(s.meta.id, 'a');
mgr.enqueueFollowup(s.meta.id, 'b');
ws.sent.length = 0;
const r = mgr.enqueueFollowup(s.meta.id, 'c');
expect(r).toEqual({ ok: false, reason: 'full' });
expect(s.queue).toEqual(['a', 'b']); // immutable: unchanged
expect(parseSent(ws).some((m) => m.type === 'queue')).toBe(false); // no broadcast
});
it('unknown id → {ok:false,reason:unknown}', () => {
const mgr = createSessionManager(CFG);
expect(mgr.enqueueFollowup('11111111-1111-4111-8111-111111111111', 'x')).toEqual({
ok: false,
reason: 'unknown',
});
});
it('exited session → {ok:false,reason:exited}', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
s.exitedAt = 2_000;
expect(mgr.enqueueFollowup(s.meta.id, 'x')).toEqual({ ok: false, reason: 'exited' });
});
});
describe('W2 inject queue — drainOne', () => {
it('pops the head, writes it to the PTY, shrinks the queue, and broadcasts depth', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.enqueueFollowup(s.meta.id, 'HEAD\r');
mgr.enqueueFollowup(s.meta.id, 'TAIL\r');
ws.sent.length = 0;
const pty = s.pty as MockIPty;
pty.writes.length = 0;
const drained = mgr.drainOne(s.meta.id);
expect(drained).toBe('HEAD\r');
expect(pty.writes).toEqual(['HEAD\r']); // byte-identical inject
expect(s.queue).toEqual(['TAIL\r']); // length now 1
expect(parseSent(ws).some((m) => m.type === 'queue' && m.length === 1)).toBe(true);
});
it('empty queue → null, no PTY write', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
const pty = s.pty as MockIPty;
pty.writes.length = 0;
expect(mgr.drainOne(s.meta.id)).toBeNull();
expect(pty.writes).toEqual([]);
});
it('exited session with items queued → null (double-guards L4), no write', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
mgr.enqueueFollowup(s.meta.id, 'x');
s.exitedAt = 2_000;
const pty = s.pty as MockIPty;
pty.writes.length = 0;
expect(mgr.drainOne(s.meta.id)).toBeNull();
expect(pty.writes).toEqual([]);
});
it('unknown id → null', () => {
const mgr = createSessionManager(CFG);
expect(mgr.drainOne('11111111-1111-4111-8111-111111111111')).toBeNull();
});
});
describe('W2 inject queue — clearQueue', () => {
it('empties the queue, broadcasts length 0, returns true', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.enqueueFollowup(s.meta.id, 'a');
mgr.enqueueFollowup(s.meta.id, 'b');
ws.sent.length = 0;
expect(mgr.clearQueue(s.meta.id)).toBe(true);
expect(s.queue).toEqual([]);
expect(parseSent(ws).some((m) => m.type === 'queue' && m.length === 0)).toBe(true);
});
it('unknown id → false', () => {
const mgr = createSessionManager(CFG);
expect(mgr.clearQueue('11111111-1111-4111-8111-111111111111')).toBe(false);
});
});
describe('W2 inject queue — list() surfaces queueLength', () => {
it('reports the pending depth per session', () => {
const mgr = createSessionManager(CFG);
nextPty = createMockPty();
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
mgr.enqueueFollowup(s.meta.id, 'a');
mgr.enqueueFollowup(s.meta.id, 'b');
const entry = mgr.list().find((e) => e.id === s.meta.id);
expect(entry?.queueLength).toBe(2);
});
});