Compare commits

...
3 changed files with 87 additions and 9 deletions

No files matched your search

@@ -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;
@@ -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`
@@ -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);