mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(daemon): preserve scrollback budget invariants
This commit is contained in:
@@ -16,6 +16,11 @@ describe('computeSessionScrollbackRows', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the default 5000-row restore depth through 25 sessions', () => {
|
||||
expect(computeSessionScrollbackRows(25)).toBe(DAEMON_SCROLLBACK_FULL_ROWS)
|
||||
expect(computeSessionScrollbackRows(26)).toBe(Math.floor(DAEMON_SCROLLBACK_BUDGET_ROWS / 26))
|
||||
})
|
||||
|
||||
it('splits the budget evenly once it binds', () => {
|
||||
expect(computeSessionScrollbackRows(40, { budgetRows: 100_000 })).toBe(2500)
|
||||
expect(computeSessionScrollbackRows(50, { budgetRows: 100_000 })).toBe(2000)
|
||||
@@ -57,6 +62,7 @@ describe('computeSessionScrollbackRows', () => {
|
||||
|
||||
it('accepts an env override and ignores an unusable one', () => {
|
||||
const env = { ORCA_DAEMON_SCROLLBACK_BUDGET_ROWS: '20000' } as NodeJS.ProcessEnv
|
||||
expect(computeSessionScrollbackRows(1, { env })).toBe(DAEMON_SCROLLBACK_FULL_ROWS)
|
||||
expect(computeSessionScrollbackRows(10, { env })).toBe(2000)
|
||||
// Why: an unparseable or non-positive override must not disable the budget — unbounded is the bug.
|
||||
for (const raw of ['nonsense', '0', '-5']) {
|
||||
@@ -66,4 +72,9 @@ describe('computeSessionScrollbackRows', () => {
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('honors a tightened budget that binds with one session', () => {
|
||||
const env = { ORCA_DAEMON_SCROLLBACK_BUDGET_ROWS: '2000' } as NodeJS.ProcessEnv
|
||||
expect(computeSessionScrollbackRows(1, { env })).toBe(2000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,7 +35,7 @@ export function computeSessionScrollbackRows(
|
||||
options.budgetRows ??
|
||||
parseBudgetOverrideRows((options.env ?? process.env)[DAEMON_SCROLLBACK_ENV_VAR]) ??
|
||||
DAEMON_SCROLLBACK_BUDGET_ROWS
|
||||
if (!Number.isFinite(liveSessionCount) || liveSessionCount <= 1) {
|
||||
if (!Number.isFinite(liveSessionCount) || liveSessionCount <= 0) {
|
||||
return DAEMON_SCROLLBACK_FULL_ROWS
|
||||
}
|
||||
const evenShare = Math.floor(budgetRows / liveSessionCount)
|
||||
|
||||
@@ -340,7 +340,15 @@ export class HeadlessEmulator {
|
||||
if (this.terminal.options.scrollback === next) {
|
||||
return
|
||||
}
|
||||
const normalBufferWasActive = this.terminal.buffer.active.type === 'normal'
|
||||
const previousNormalLength = this.terminal.buffer.normal.length
|
||||
this.terminal.options.scrollback = next
|
||||
const trimmedRows = previousNormalLength - this.terminal.buffer.normal.length
|
||||
if (normalBufferWasActive && trimmedRows > 0) {
|
||||
this.restoredOscLinks = this.restoredOscLinks
|
||||
.filter((link) => link.row >= trimmedRows)
|
||||
.map((link) => ({ ...link, row: link.row - trimmedRows }))
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
|
||||
@@ -89,6 +89,16 @@ describe('TerminalHost scrollback budget', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not scan every session again when attaching to an existing session', async () => {
|
||||
await create('a')
|
||||
await create('b')
|
||||
applyRows.mockClear()
|
||||
|
||||
await create('a')
|
||||
|
||||
expect(applyRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reduces per-session depth as the session count grows past the budget', async () => {
|
||||
const SESSIONS = 60
|
||||
for (let i = 0; i < SESSIONS; i++) {
|
||||
@@ -99,6 +109,13 @@ describe('TerminalHost scrollback budget', () => {
|
||||
expect(applied).toBeLessThan(DAEMON_SCROLLBACK_FULL_ROWS)
|
||||
})
|
||||
|
||||
it('applies each concurrent create before yielding', async () => {
|
||||
const pending = Array.from({ length: 30 }, (_, i) => create(`concurrent-${i}`))
|
||||
|
||||
expect(lastAppliedRows()).toBe(computeSessionScrollbackRows(30))
|
||||
await Promise.all(pending)
|
||||
})
|
||||
|
||||
it('restores depth to survivors when sessions exit', async () => {
|
||||
const SESSIONS = 60
|
||||
for (let i = 0; i < SESSIONS; i++) {
|
||||
@@ -117,3 +134,47 @@ describe('TerminalHost scrollback budget', () => {
|
||||
expect(afterDrain).toBe(DAEMON_SCROLLBACK_FULL_ROWS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HeadlessEmulator scrollback budget application', () => {
|
||||
it('trims immediately without misplacing restored OSC links', () => {
|
||||
const emulator = new HeadlessEmulator({ cols: 20, rows: 2, scrollback: 20 })
|
||||
try {
|
||||
emulator.writeSync(Array.from({ length: 10 }, (_, i) => `L${i}\r\n`).join(''))
|
||||
emulator.setRestoredOscLinks([
|
||||
{ row: 8, startCol: 0, endCol: 2, uri: 'https://retained.invalid' },
|
||||
{ row: 2, startCol: 0, endCol: 2, uri: 'https://trimmed.invalid' }
|
||||
])
|
||||
|
||||
emulator.setRetainedScrollbackRows(3)
|
||||
|
||||
const snapshot = emulator.getSnapshot()
|
||||
expect(snapshot.scrollbackLines).toBe(3)
|
||||
expect(snapshot.oscLinks).toEqual([
|
||||
{ row: 2, startCol: 0, endCol: 2, uri: 'https://retained.invalid' }
|
||||
])
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps alternate-buffer restored OSC links fixed while trimming the normal buffer', () => {
|
||||
const emulator = new HeadlessEmulator({ cols: 20, rows: 2, scrollback: 20 })
|
||||
try {
|
||||
emulator.writeSync(Array.from({ length: 10 }, (_, i) => `L${i}\r\n`).join(''))
|
||||
emulator.writeSync('\x1b[?1049hALT')
|
||||
emulator.setRestoredOscLinks([
|
||||
{ row: 0, startCol: 0, endCol: 3, uri: 'https://alternate.invalid' }
|
||||
])
|
||||
|
||||
emulator.setRetainedScrollbackRows(3)
|
||||
|
||||
const snapshot = emulator.getSnapshot()
|
||||
expect(snapshot.scrollbackLines).toBe(3)
|
||||
expect(snapshot.oscLinks).toEqual([
|
||||
{ row: 0, startCol: 0, endCol: 3, uri: 'https://alternate.invalid' }
|
||||
])
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,15 +65,17 @@ export class TerminalHost {
|
||||
spawnSubprocess: this.spawnSubprocess,
|
||||
creationFenced: this.creationFenced,
|
||||
onDeadSessionRemoved: (sessionId) => this.agentSessionGenerations.forget(sessionId),
|
||||
onSessionCreated: (sessionId, generation, isAlive) =>
|
||||
this.agentSessionGenerations.remember(sessionId, generation, isAlive),
|
||||
onSessionCreated: (sessionId, generation, isAlive) => {
|
||||
this.agentSessionGenerations.remember(sessionId, generation, isAlive)
|
||||
// Apply synchronously with map insertion so concurrent creates cannot retain full depth.
|
||||
this.applyScrollbackBudget()
|
||||
},
|
||||
onSessionExit: (sessionId, generation) => {
|
||||
this.agentSessionOwners.release(sessionId, generation)
|
||||
this.agentSessionGenerations.forget(sessionId, generation)
|
||||
this.reapSession(sessionId)
|
||||
}
|
||||
})
|
||||
this.applyScrollbackBudget()
|
||||
return result
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user