mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
fix(compaction): read small panes at a readable size and retain end reasons (#2445)
Managed compaction now works on a small Claude pane, such as a tile in a follow view. The /compact send's readiness probe and its final input check read the pane at a readable size, using the existing readable capture, which pins the window, waits for the redraw and restores the size exactly; the uncertain post-compact read does too. The managed send's idle wait includes the readable budget, still bounded by the attempt's deadline, so the larger read can finish. Ordinary sends are unchanged, and a pane that still can't be classified stays unknown, never idle. An ended attempt now records its reason in COMPACTION-STATE.json. Reported by the Ops team on an 11x20 pane.
This commit is contained in:
1 parent
9819da9d5e
commit
98f0f4939c
8 files changed
+102
-16
No files matched your search
@@ -484,12 +484,17 @@ describe("PUBLIC bin ownership (guard finding 1 — the wrapper is the real fron
|
||||
// "PRODUCTION PATH … REAL probeFrontDoor" test earlier, with no real TUI spawned.)
|
||||
|
||||
it.skipIf(!existsSync(binWrapper))("piped PUBLIC bin keeps the commander usage baseline (no TUI, fast exit)", () => {
|
||||
const started = Date.now();
|
||||
const result = spawnSync(process.execPath, [binWrapper], {
|
||||
input: "x\n",
|
||||
timeout: 5000,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.status, JSON.stringify({
|
||||
elapsedMs: Date.now() - started, status: result.status, signal: result.signal,
|
||||
error: result.error && { name: result.error.name, message: result.error.message, code: (result.error as NodeJS.ErrnoException).code },
|
||||
stdout: result.stdout?.slice(-4096), stderr: result.stderr?.slice(-4096),
|
||||
})).toBe(0);
|
||||
expect(`${result.stderr}${result.stdout}`).toMatch(/Usage: rig/);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TerminalActor, TerminalPolicyStore } from "./terminal-policy.js";
|
||||
import { TerminalPolicyRefusal } from "./terminal-policy.js";
|
||||
import { DeliveryGuardError } from "./seat-delivery-guard.js";
|
||||
import type { SessionTransport, SendResult } from "./session-transport.js";
|
||||
import { CAPTURE_READABLE_BUDGET_MS, type SessionTransport, type SendResult } from "./session-transport.js";
|
||||
import type { ClaudeCompactionPolicy, SettingsStore } from "./user-settings/settings-store.js";
|
||||
import { postCompactExtraSetting } from "./managed-restore-read-access.js";
|
||||
import * as fs from "node:fs";
|
||||
@@ -503,6 +503,8 @@ interface CompactionRecord {
|
||||
* `compact_uncertain`: the attempt stopped after a /compact whose outcome is unknown, so its restore is owed if it ran.
|
||||
* `done`: the post-restore audit was sent. `ended`: the attempt stopped or was cancelled. */
|
||||
phase: "preparing" | PendingPostCompactStage | "compact_uncertain" | "done" | "ended";
|
||||
/** Diagnostic only; older records may omit the reason an attempt stopped. */
|
||||
reason?: string;
|
||||
delivery: PreparationState["delivery"];
|
||||
deadlineAt: number | null;
|
||||
sends: number;
|
||||
@@ -1368,11 +1370,11 @@ export class ClaudeCompactionEnforcer {
|
||||
};
|
||||
const send = async () => {
|
||||
check();
|
||||
// Both paths send only to an idle seat, with the same idle wait, bounded by the attempt's existing
|
||||
// deadline. (The automatic path used to send into a seat still finishing its preparation turn.)
|
||||
// Both paths send only to an idle seat. Include the readable observation's budget in the wait,
|
||||
// still bounded by the attempt's existing deadline.
|
||||
// Idle comes from a live pane read: the newest hook can predate the rest of the preparation turn.
|
||||
return this.sessionTransport.send(input.sessionName, composed.command,
|
||||
{ openrigNotice: true, preserveInput: true, beforeWrite, ...(attempt.deadlineAt !== null ? { waitForIdleMs: Math.max(1, Math.min(this.compactSendIdleWaitMs, this.compactDeadline(attempt)! - this.now())), readinessFromPaneOnly: true } : {}) });
|
||||
{ openrigNotice: true, preserveInput: true, beforeWrite, ...(attempt.deadlineAt !== null ? { waitForIdleMs: Math.max(1, Math.min(this.compactSendIdleWaitMs + CAPTURE_READABLE_BUDGET_MS, this.compactDeadline(attempt)! - this.now())), readinessFromPaneOnly: true } : {}) });
|
||||
};
|
||||
let result: Pick<SendResult, "ok" | "reason" | "outcome" | "sent" | "inputPhase">;
|
||||
let guardRefused = false;
|
||||
@@ -1428,7 +1430,7 @@ export class ClaudeCompactionEnforcer {
|
||||
* why it couldn't be read. Never throws. */
|
||||
private async inputBoxStatus(sessionName: string): Promise<string> {
|
||||
try {
|
||||
const capture = await this.sessionTransport.capture(sessionName, { inputBox: true });
|
||||
const capture = await this.sessionTransport.capture(sessionName, { inputBox: true, readable: { width: 120, height: 40 } });
|
||||
return capture.inputBox?.status ?? capture.reason ?? "unknown";
|
||||
} catch { return "unknown"; }
|
||||
}
|
||||
@@ -1505,9 +1507,9 @@ export class ClaudeCompactionEnforcer {
|
||||
} catch { try { fs.rmSync(temporary, { force: true }); } catch { /* nothing to clean */ } }
|
||||
}
|
||||
|
||||
private persistStage(sessionName: string, phase: CompactionRecord["phase"]): void {
|
||||
private persistStage(sessionName: string, phase: CompactionRecord["phase"], reason?: string): void {
|
||||
const current = this.records.get(sessionName);
|
||||
if (current) this.persist(sessionName, current.record.attemptId, { phase, lastDelivery: undefined });
|
||||
if (current) this.persist(sessionName, current.record.attemptId, { phase, ...(reason !== undefined ? { reason } : {}), lastDelivery: undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1593,7 +1595,7 @@ export class ClaudeCompactionEnforcer {
|
||||
const attempt = this.pendingPreCompactPrep.get(session);
|
||||
if ((expected && expected !== attempt) || !attempt || attempt.status === "stopped" || attempt.status === "compact-sent") return;
|
||||
attempt.status = "stopped"; attempt.reason = reason; attempt.stoppedAt = this.now(); attempt.controller.abort();
|
||||
this.persist(session, attempt.attemptId, { phase: attempt.compact === "uncertain" ? "compact_uncertain" : "ended" });
|
||||
this.persist(session, attempt.attemptId, { phase: attempt.compact === "uncertain" ? "compact_uncertain" : "ended", reason });
|
||||
if (attempt.mode === "manual") this.setManualStage(session, "skipped-or-failed", reason);
|
||||
}
|
||||
|
||||
@@ -1609,7 +1611,7 @@ export class ClaudeCompactionEnforcer {
|
||||
attempt.reason = "compaction_cancelled";
|
||||
attempt.controller.abort();
|
||||
}
|
||||
this.persistStage(session, "ended");
|
||||
this.persistStage(session, "ended", "compaction_cancelled");
|
||||
this.pendingPostCompactRestore.delete(session);
|
||||
this.pendingStageGeneration.delete(session);
|
||||
this.restoreCarry.delete(session);
|
||||
|
||||
@@ -3288,8 +3288,11 @@ export class SessionTransport {
|
||||
if (cancel?.aborted) return observe({ ...callerCancelled(sessionName), ...(waitMode ? waitEvidence : {}) });
|
||||
const beforePaste = cancel ? () => { opts?.beforeWrite?.(); if (cancel.aborted) throw new DeliveryGuardError(CALLER_CANCELLED, "caller cancelled before the paste"); } : opts?.beforeWrite;
|
||||
const preserveInput = opts?.preserveInput ? async () => {
|
||||
const box = runtime === "claude-code" || runtime === "codex"
|
||||
? await this.captureInputBox(readTarget, runtime, { remainingMs: INPUT_BOX_READ_DEADLINE_MS }) : null;
|
||||
const readBox = async () => runtime === "claude-code" || runtime === "codex"
|
||||
? this.captureInputBox(readTarget, runtime, { remainingMs: INPUT_BOX_READ_DEADLINE_MS }) : null;
|
||||
const box = opts?.readinessFromPaneOnly && runtime === "claude-code"
|
||||
? (await this.readAtReadableSize(sessionName, { width: 120, height: 40 }, { remainingMs: CAPTURE_READABLE_BUDGET_MS }, readBox)).value
|
||||
: await readBox();
|
||||
if (bindingChanged()) throw new DeliveryGuardError("draft_input_changed", "The managed input target changed; no input was written.");
|
||||
if (box?.status !== "empty" && !(box?.status === "likely-suggestion" && box.confidence === "high")) {
|
||||
throw new DeliveryGuardError(box?.status === "typed" ? "draft_input_busy" : "draft_input_unknown",
|
||||
@@ -3794,7 +3797,7 @@ export class SessionTransport {
|
||||
}
|
||||
}
|
||||
|
||||
const probe = await probeSessionActivity({
|
||||
const readPane = () => probeSessionActivity({
|
||||
communityHarness, communityHarnessSendProbe: true,
|
||||
composerSnapshot,
|
||||
sessionName: input.sessionName,
|
||||
@@ -3808,6 +3811,10 @@ export class SessionTransport {
|
||||
timerlessStatusIsLive: input.readinessFromPaneOnly === true,
|
||||
postCompactTurnBoundary: input.postCompactTurnBoundary === true,
|
||||
});
|
||||
// Managed compaction must read a tiled Claude pane at the same size as a readable capture.
|
||||
const probe = input.readinessFromPaneOnly && input.runtime === "claude-code"
|
||||
? (await this.readAtReadableSize(input.sessionName, { width: 120, height: 40 }, { remainingMs: CAPTURE_READABLE_BUDGET_MS }, readPane)).value
|
||||
: await readPane();
|
||||
// A Codex empty-composer placeholder is also on screen while Codex streams with its status
|
||||
// row hidden, so a placeholder-only idle verdict must not override a display-fresh (<5min)
|
||||
// running/needs_input hook such as UserPromptSubmit: keep it until it ages out. An `unknown`
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
||||
import { ClaudeCompactionEnforcer as ActualEnforcer, buildPostCompactRestorePrompt } from "../src/domain/claude-compaction-enforcer.js";
|
||||
import type { SessionTransport } from "../src/domain/session-transport.js";
|
||||
import { CAPTURE_READABLE_BUDGET_MS, type SessionTransport } from "../src/domain/session-transport.js";
|
||||
import type { ClaudeCompactionPolicy, SettingsStore } from "../src/domain/user-settings/settings-store.js";
|
||||
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
@@ -1332,7 +1332,7 @@ describe("ClaudeCompactionEnforcer", () => {
|
||||
return { ok: true, activity: { state: "idle" } as any, attempts: 1, waitedMs: 0 };
|
||||
};
|
||||
await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
|
||||
expect(callOrder).toEqual(["prep", "idle", `compact:${JSON.stringify({ openrigNotice: true, preserveInput: true, waitForIdleMs: 1000, readinessFromPaneOnly: true })}`]);
|
||||
expect(callOrder).toEqual(["prep", "idle", `compact:${JSON.stringify({ openrigNotice: true, preserveInput: true, waitForIdleMs: 1_000 + CAPTURE_READABLE_BUDGET_MS, readinessFromPaneOnly: true })}`]);
|
||||
});
|
||||
|
||||
it.each([0, 75])("wait-for-idle failure means /compact never landed: the back-half is NOT seeded (preparation delay=%ims)", async (delayMs) => {
|
||||
|
||||
@@ -107,6 +107,7 @@ describe("post-compact drain readiness", () => {
|
||||
});
|
||||
tmux = {
|
||||
hasSession: async () => true,
|
||||
readWindow: async () => null,
|
||||
probeSession: async () => ({ state: "present" }),
|
||||
getPanePid: async () => null,
|
||||
getPaneCommand: async () => null,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {compactionRoutes} from '../src/routes/compaction.js';
|
||||
import {mkdtempSync, mkdirSync, writeFileSync, renameSync, rmSync, readFileSync, symlinkSync, lstatSync, statSync, utimesSync, chmodSync, existsSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join, dirname} from 'node:path';
|
||||
import {ClaudeCompactionEnforcer, AUTO_PREP_WAIT_MS_DEFAULT, AUTO_RETRY_AFTER_STOP_MS_DEFAULT, MANUAL_PREP_WAIT_MS_DEFAULT} from '../src/domain/claude-compaction-enforcer.js';
|
||||
import {ClaudeCompactionEnforcer, COMPACTION_RECORD_FILE, AUTO_PREP_WAIT_MS_DEFAULT, AUTO_RETRY_AFTER_STOP_MS_DEFAULT, MANUAL_PREP_WAIT_MS_DEFAULT} from '../src/domain/claude-compaction-enforcer.js';
|
||||
import {SessionTransport} from '../src/domain/session-transport.js';
|
||||
import {SeatDeliveryGuard} from '../src/domain/seat-delivery-guard.js';
|
||||
import {TmuxAdapter} from '../src/adapters/tmux.js';
|
||||
@@ -40,6 +40,75 @@ function stageMap(f:ReturnType<typeof fixture>,suffix=''){
|
||||
writeFileSync(a.mapPath+'.tmp',`# Restore map\nCurrent work and next step.\n${a.marker}${suffix}\n`);
|
||||
}
|
||||
const compacts=(f:ReturnType<typeof fixture>)=>f.writes.filter(t=>t.startsWith('/compact'));
|
||||
// Real readable capture and managed send, including the adapter's final beforePaste check.
|
||||
async function tinyPane(kind:'empty'|'unknown'|'typed'='empty',liveProbe=true){
|
||||
const f=fixture();vi.spyOn(Date,'now').mockImplementation(()=>f.clock());
|
||||
await f.e.maybeAutoCompact(input);stageMap(f);
|
||||
const frame=readFileSync(join(import.meta.dirname,'fixtures/input-box/claude-code-2.1.296/openrig-seat-accept-edits-idle.txt'),'utf8').trimEnd().split('\n');
|
||||
const row=frame.findLastIndex(line=>line.startsWith('❯'));
|
||||
if(kind==='typed')frame[row]='❯ keep this draft';
|
||||
const window={width:11,height:20,own:null as string|null,recorded:''};
|
||||
const screen=()=>window.width<120?'clipped pane':kind==='unknown'?'unclassified screen':`\x1b[0m${frame.join('\n')}`;
|
||||
const tmux=Object.assign(f.tmux,{
|
||||
hasSession:vi.fn(async()=>true),
|
||||
readWindow:vi.fn(async()=>({id:'@tiny',userOption:window.recorded})),
|
||||
listPanes:vi.fn(async()=>[{id:'%1',index:0,cwd:'/project',width:window.width,height:window.height,active:true}]),
|
||||
getWindowLocalOption:vi.fn(async()=>window.own),
|
||||
setWindowOption:vi.fn(async(_target:string,option:string,value:string)=>{if(option==='window-size')window.own=value;else window.recorded=value;return{ok:true};}),
|
||||
restoreWindowOption:vi.fn(async(_target:string,option:string,value:string|null)=>{if(option==='window-size')window.own=value;else window.recorded=value??'';return{ok:true};}),
|
||||
resizeWindow:vi.fn(async(_target:string,width:number,height:number)=>{window.width=width;window.height=height;window.own='manual';return{ok:true};}),
|
||||
getPaneCursorPosition:vi.fn(async()=>({x:kind==='typed'?17:2,y:row,width:window.width,height:window.height})),
|
||||
capturePaneContent:vi.fn(async()=>screen()),capturePaneScreen:vi.fn(async()=>screen()),
|
||||
sendText:vi.fn(async(_target:string,text:string,beforeInput?:()=>void,options?:{beforePaste?:()=>Promise<void>})=>{
|
||||
try{await options?.beforePaste?.();beforeInput?.();f.writes.push(text);return{ok:true};}
|
||||
catch(error){return{ok:false,code:(error as {code?:string}).code??'fixture_error',message:String(error)};}
|
||||
}),
|
||||
});
|
||||
if(liveProbe)delete(f.transport as any).classifySendReadiness;
|
||||
return{...f,tmux,window};
|
||||
}
|
||||
it.each(['live-pane','final-composer'])('submits managed /compact from an 11x20 pane through the %s readable read and restores the window',async gate=>{
|
||||
const f=await tinyPane('empty',gate==='live-pane');
|
||||
// The pre-existing readable path can classify this fixture and give the inherited sizing back.
|
||||
expect((await f.transport.capture(seat,{inputBox:true,readable:{width:120,height:40}})).inputBox).toMatchObject({status:'empty',confidence:'high'});
|
||||
expect(f.window).toEqual({width:11,height:20,own:null,recorded:''});
|
||||
await f.e.maybeAutoCompact(input);
|
||||
expect(f.window).toEqual({width:11,height:20,own:null,recorded:''});
|
||||
expect(compacts(f)).toHaveLength(1);
|
||||
expect(f.tmux.sendText).toHaveBeenCalledWith(seat,expect.stringMatching(/^\/compact/),expect.any(Function),expect.objectContaining({beforePaste:expect.any(Function)}));
|
||||
});
|
||||
it.each(['unknown','typed'] as const)('keeps a readable %s composer from authorizing /compact and gives its window back',async kind=>{
|
||||
const f=await tinyPane(kind);
|
||||
const box=(await f.transport.capture(seat,{inputBox:true,readable:{width:120,height:40}})).inputBox;
|
||||
expect(box?.status).toBe(kind==='typed'?'typed':'no-input-box');
|
||||
await f.e.maybeAutoCompact(input);
|
||||
expect(compacts(f)).toHaveLength(0);
|
||||
expect(f.window).toEqual({width:11,height:20,own:null,recorded:''});
|
||||
});
|
||||
it('submits managed /compact when readable redraw settling exceeds the ordinary one-second idle wait',async()=>{
|
||||
const f=await tinyPane();
|
||||
expect((await f.transport.capture(seat,{inputBox:true,readable:{width:120,height:40}})).inputBox).toMatchObject({status:'empty',confidence:'high'});
|
||||
const screen=f.tmux.capturePaneScreen.getMockImplementation()!;
|
||||
let looks=0;
|
||||
f.tmux.capturePaneScreen.mockImplementation(async()=>++looks<=8?`redrawing ${looks}`:screen());
|
||||
const started=f.clock();
|
||||
await f.e.maybeAutoCompact(input);
|
||||
expect(f.clock()-started).toBeGreaterThan(1000);
|
||||
expect(looks).toBeGreaterThan(8);
|
||||
expect(f.window).toEqual({width:11,height:20,own:null,recorded:''});
|
||||
expect(compacts(f)).toHaveLength(1);
|
||||
});
|
||||
it.each(['incomplete-map','completed-map','cancel-after-compact'] as const)('persists the computed end reason for %s',async end=>{
|
||||
const f=fixture();await f.e.maybeAutoCompact(input);
|
||||
if(end!=='incomplete-map')stageMap(f);
|
||||
if(end==='cancel-after-compact'){await f.e.maybeAutoCompact(input);expect(compacts(f)).toHaveLength(1);f.e.cancelPreparation(seat);}
|
||||
else{f.advance(AUTO_PREP_WAIT_MS_DEFAULT+(end==='completed-map'?AUTO_RETRY_AFTER_STOP_MS_DEFAULT:0)+1);f.e.reconcilePreparations();}
|
||||
const state=f.e.getPreparationState(seat)!;
|
||||
const reason=end==='incomplete-map'?'preparation_incomplete':end==='completed-map'?'map_complete_seat_never_idle':'compaction_cancelled';
|
||||
expect(state).toMatchObject({status:'stopped',reason});
|
||||
const record=JSON.parse(readFileSync(join(dirname(state.mapPath),COMPACTION_RECORD_FILE),'utf8'));
|
||||
expect(record.phase).toBe('ended');expect(record.reason).toBe(reason);
|
||||
});
|
||||
it('polls wait after delivered prep, ordinary real transport still circulates, exact map releases once',async()=>{
|
||||
const f=fixture();expect(AUTO_PREP_WAIT_MS_DEFAULT).toBe(25*60_000);
|
||||
await f.e.maybeAutoCompact(input);await f.e.maybeAutoCompact(input);expect(compacts(f)).toHaveLength(0);
|
||||
|
||||
@@ -42,6 +42,7 @@ function makeSettings(): SettingsStore {
|
||||
function idleTmux(sendText?: (target: string, text: string) => Promise<{ ok: true }>, sendKeys?: () => Promise<{ ok: true }>): TmuxAdapter {
|
||||
return {
|
||||
hasSession: async () => true,
|
||||
readWindow: async () => null,
|
||||
probeSession: async () => ({ state: "present" as const }),
|
||||
sendText: sendText ?? (async () => ({ ok: true as const })),
|
||||
sendKeys: sendKeys ?? (async () => ({ ok: true as const })),
|
||||
|
||||
@@ -121,6 +121,7 @@ describe("through the real send: the turn boundary reaches a just-compacted seat
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: "worker@my-rig" });
|
||||
const tmux = {
|
||||
hasSession: async () => true, probeSession: async () => ({ state: "present" as const }),
|
||||
readWindow: async () => null,
|
||||
capturePaneContent: async () => pane, getPaneCommand: async () => null, getPanePid: async () => null,
|
||||
sendText, sendKeys,
|
||||
} as unknown as TmuxAdapter;
|
||||
|
||||
Reference in new issue
Block a user