mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
Avoid repeated runtime imports and recovery fixture seeding (#25155)
This commit is contained in:
@@ -1941,6 +1941,49 @@ remained red. Its review recognized all five shards as a complete reference
|
||||
(10,608 files, 8,965,977 worker-ms). This was again a full fallback with
|
||||
`selectionEvaluated: false`, not evidence for enabling selected tests.
|
||||
|
||||
## October 4 runtime imports and recovery fixtures
|
||||
|
||||
Three helper-only tests now import the existing terminal modules directly rather
|
||||
than initializing the runtime service. Ten copied-loop cases never exercised
|
||||
runtime memoization: they passed with its cache, timestamp update or prune
|
||||
invalidation disabled. Two actual helper checks remain. The existing runtime
|
||||
prune suite now exercises real leaf cache reuse, split prompt timestamps,
|
||||
ordinary output, fresh prompts and detection after retained-history eviction.
|
||||
Each of those three production faults fails a real runtime assertion.
|
||||
|
||||
Recovery tests now seed three exact fixture variants once, after the seed child
|
||||
has closed. Each crash still receives an independent byte-for-byte copy of the
|
||||
entire database/WAL family and remapped paths. Buffer.equals retains exact byte
|
||||
comparison without recursive matcher overhead. All 46 original crash boundaries
|
||||
and retries remain. Four additional copy-isolation/WAL checks run, and teardown
|
||||
requires that all seed bytes remain unchanged after the full suite.
|
||||
|
||||
Three alternating one-worker hosted ARM pairs in
|
||||
[37182181976](https://github.com/stablyai/orca/actions/runs/37182181976)
|
||||
measured these complete invocations:
|
||||
|
||||
| Cohort | Baseline seconds | Candidate seconds | Median saving |
|
||||
| --- | --- | --- | --- |
|
||||
| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% |
|
||||
| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% |
|
||||
| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% |
|
||||
|
||||
The final runtime cohort has seven real cases versus 16 including the copied
|
||||
loops; its new runtime case is included in candidate timing. Recovery has 50
|
||||
passes versus the original 46. Hosted Node typecheck passed. Recovery faults for
|
||||
last-byte database/WAL corruption, shared database paths, missing WAL copies and
|
||||
accepted/unaccepted seed collision failed the intended assertions. These are
|
||||
focused workload savings, not measured whole-shard or queue-delay gains.
|
||||
|
||||
An independent local cache screen left both caches disabled. Across 14 unchanged
|
||||
files and 92 cases, a warm Vitest transform cache reduced median invocation time
|
||||
3.090 to 1.948 seconds, excluding archive costs; its cold arm increased time to
|
||||
3.281 seconds. Node compilation caching showed no gain. Controls reproduced stale
|
||||
transforms after TypeScript configuration or plugin-option changes, so persisted
|
||||
reuse requires a complete transform-input stamp and hosted net-cost evidence.
|
||||
A separate 130,000-pane leaf-collection optimization was restored: its complete
|
||||
migration-file timing stayed within noise. The regression fixture remains.
|
||||
|
||||
## October 3 removal fixture cleanup ordering
|
||||
|
||||
[37105566358](https://github.com/stablyai/orca/actions/runs/37105566358)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { basename, dirname, join, relative } from 'node:path'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setSecretStore } from '../../../shared/secret-store'
|
||||
import { profileStateStorage } from '../../orca-profiles/profile-project-state-file'
|
||||
@@ -103,13 +104,25 @@ afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
afterAll(() => rmSync(suiteRoot, { recursive: true, force: true }))
|
||||
afterAll(() => {
|
||||
try {
|
||||
for (const seed of seededFixtures.values()) {
|
||||
for (const [suffix, bytes] of seed.originalFamily) {
|
||||
expect(readFileSync(`${seed.databasePath}${suffix}`).equals(bytes)).toBe(true)
|
||||
}
|
||||
expect(readFileSync(seed.backupPath).equals(seed.backupBytes)).toBe(true)
|
||||
expect(readFileSync(seed.exportPath, 'utf8')).toBe(selectedJson)
|
||||
}
|
||||
} finally {
|
||||
rmSync(suiteRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
type Fixture = RecoveryCrashOptions & { backupBytes: Buffer; originalFamily: Map<string, Buffer> }
|
||||
const seededFixtures = new Map<string, Fixture>()
|
||||
|
||||
async function fixture(kind: 'json' | 'sqlite', accepted: boolean): Promise<Fixture> {
|
||||
const root = mkdtempSync(join(suiteRoot, 'profile-'))
|
||||
fixtureRoots.push(root)
|
||||
async function seedFixture(kind: 'json' | 'sqlite', accepted: boolean): Promise<Fixture> {
|
||||
const root = mkdtempSync(join(suiteRoot, 'seed-'))
|
||||
const directory = join(root, 'profiles', profileId)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeFileSync(
|
||||
@@ -173,6 +186,39 @@ async function fixture(kind: 'json' | 'sqlite', accepted: boolean): Promise<Fixt
|
||||
return { ...options, backupBytes: readFileSync(backupPath), originalFamily }
|
||||
}
|
||||
|
||||
function cloneFixture(seed: Fixture): Fixture {
|
||||
const root = mkdtempSync(join(suiteRoot, 'profile-'))
|
||||
fixtureRoots.push(root)
|
||||
cpSync(seed.root, root, { recursive: true })
|
||||
const options = {
|
||||
...seed,
|
||||
root,
|
||||
dataFile: join(root, relative(seed.root, seed.dataFile)),
|
||||
databasePath: join(root, relative(seed.root, seed.databasePath)),
|
||||
exportPath: join(root, relative(seed.root, seed.exportPath)),
|
||||
backupPath: join(root, relative(seed.root, seed.backupPath)),
|
||||
markerPath: join(root, relative(seed.root, seed.markerPath))
|
||||
}
|
||||
const originalFamily = new Map(
|
||||
['', '-wal', '-shm', '-journal'].map((suffix) => [
|
||||
suffix,
|
||||
readFileSync(`${options.databasePath}${suffix}`)
|
||||
])
|
||||
)
|
||||
return { ...options, backupBytes: readFileSync(options.backupPath), originalFamily }
|
||||
}
|
||||
|
||||
async function fixture(kind: 'json' | 'sqlite', accepted: boolean): Promise<Fixture> {
|
||||
const key = `${kind}/${accepted}`
|
||||
let seed = seededFixtures.get(key)
|
||||
if (!seed) {
|
||||
// The seed child's close event has fired before its WAL family is copied.
|
||||
seed = await seedFixture(kind, accepted)
|
||||
seededFixtures.set(key, seed)
|
||||
}
|
||||
return cloneFixture(seed)
|
||||
}
|
||||
|
||||
function readSqlite(path: string): unknown {
|
||||
const opened = openProfileStateDatabaseReadOnly(path, profileId)
|
||||
try {
|
||||
@@ -192,10 +238,12 @@ function assertQuarantine(profile: Fixture): void {
|
||||
const directory = join(dirname(profile.databasePath), quarantine)
|
||||
// Check exact family bytes before opening the copied WAL snapshot.
|
||||
for (const [suffix, bytes] of profile.originalFamily) {
|
||||
expect(readFileSync(join(directory, `profile-state.db${suffix}`))).toEqual(bytes)
|
||||
expect(readFileSync(join(directory, `profile-state.db${suffix}`)).equals(bytes)).toBe(true)
|
||||
}
|
||||
expect(readFileSync(join(directory, basename(profile.exportPath)), 'utf8')).toBe(selectedJson)
|
||||
expect(readFileSync(join(directory, basename(profile.backupPath)))).toEqual(profile.backupBytes)
|
||||
expect(
|
||||
readFileSync(join(directory, basename(profile.backupPath))).equals(profile.backupBytes)
|
||||
).toBe(true)
|
||||
expect(readSqlite(join(directory, 'profile-state.db'))).toEqual(oldState)
|
||||
}
|
||||
|
||||
@@ -346,7 +394,7 @@ describe('SQLite recovery process death', () => {
|
||||
const profile = await fixture('sqlite', true)
|
||||
await killRecoveryAt(bundle, profile, stage(profile, boundary))
|
||||
assertQuarantine(profile)
|
||||
expect(readFileSync(profile.backupPath)).toEqual(profile.backupBytes)
|
||||
expect(readFileSync(profile.backupPath).equals(profile.backupBytes)).toBe(true)
|
||||
const expected = [
|
||||
'marker-invalidated',
|
||||
'selected-export',
|
||||
@@ -365,7 +413,7 @@ describe('SQLite recovery process death', () => {
|
||||
: 'refused'
|
||||
assertRestart(profile, expected)
|
||||
retry(profile)
|
||||
expect(readFileSync(profile.backupPath)).toEqual(profile.backupBytes)
|
||||
expect(readFileSync(profile.backupPath).equals(profile.backupBytes)).toBe(true)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
@@ -378,3 +426,38 @@ describe('SQLite recovery process death', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('seeded recovery fixture copies', () => {
|
||||
it.each([
|
||||
['json', false],
|
||||
['json', true],
|
||||
['sqlite', true]
|
||||
] as const)(
|
||||
'isolates %s/accepted=%s through corruption and WAL reopen',
|
||||
async (kind, accepted) => {
|
||||
const first = await fixture(kind, accepted)
|
||||
const second = await fixture(kind, accepted)
|
||||
const seed = seededFixtures.get(`${kind}/${accepted}`)
|
||||
if (!seed) {
|
||||
throw new Error('Seed fixture was not retained')
|
||||
}
|
||||
expect(new Set([first.root, second.root, seed.root]).size).toBe(3)
|
||||
for (const [suffix, bytes] of seed.originalFamily) {
|
||||
expect(readFileSync(`${first.databasePath}${suffix}`).equals(bytes)).toBe(true)
|
||||
writeFileSync(`${first.databasePath}${suffix}`, 'corrupted-copy')
|
||||
expect(readFileSync(`${second.databasePath}${suffix}`).equals(bytes)).toBe(true)
|
||||
expect(readFileSync(`${seed.databasePath}${suffix}`).equals(bytes)).toBe(true)
|
||||
}
|
||||
const third = await fixture(kind, accepted)
|
||||
expect(readSqlite(third.databasePath)).toEqual(oldState)
|
||||
expect(readFileSync(seed.backupPath).equals(seed.backupBytes)).toBe(true)
|
||||
expect(readFileSync(seed.exportPath, 'utf8')).toBe(selectedJson)
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects an incomplete copied WAL family before a recovery child starts', async () => {
|
||||
const incomplete = await fixture('json', false)
|
||||
rmSync(`${incomplete.databasePath}-wal`)
|
||||
expect(() => cloneFixture(incomplete)).toThrow(/ENOENT/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,124 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
appendNormalizedToTailBuffer,
|
||||
buildPreview,
|
||||
computeTerminalTailWaitState,
|
||||
tailGainedNewerBlockedReason,
|
||||
type TerminalTailWaitState
|
||||
} from './orca-runtime'
|
||||
import { computeTerminalTailWaitState } from './terminal-wait-tail-state'
|
||||
|
||||
// These tests pin the onPtyData wait-detection memoization: caching the
|
||||
// post-append wait state and reusing it as the next chunk's pre-append state
|
||||
// must produce byte-for-byte the same waitBlockedAt stamping as recomputing the
|
||||
// wait-state scan on both sides of every chunk (the pre-memoization behavior),
|
||||
// while doing roughly half the state computations.
|
||||
|
||||
type RedrawCursor = ReturnType<typeof appendNormalizedToTailBuffer>['redrawCursor']
|
||||
|
||||
type TailSim = {
|
||||
tailBuffer: string[]
|
||||
tailPartialLine: string
|
||||
tailRedrawCursor: RedrawCursor
|
||||
preview: string
|
||||
waitBlockedAt: number | null
|
||||
tailWaitState?: TerminalTailWaitState
|
||||
}
|
||||
|
||||
function newSim(): TailSim {
|
||||
return {
|
||||
tailBuffer: [],
|
||||
tailPartialLine: '',
|
||||
tailRedrawCursor: null,
|
||||
preview: '',
|
||||
waitBlockedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
type Compute = typeof computeTerminalTailWaitState
|
||||
|
||||
// Mirrors the memoized onPtyData tail loop: reuse the cached tail-derived state
|
||||
// as the previous state; only recompute it on a preview-fallback (empty tail).
|
||||
function stepMemoized(sim: TailSim, chunk: string, at: number, compute: Compute): void {
|
||||
const previousWaitState =
|
||||
sim.tailWaitState?.fromTail === true
|
||||
? sim.tailWaitState
|
||||
: compute(sim.tailBuffer, sim.tailPartialLine, sim.preview)
|
||||
const nextTail = appendNormalizedToTailBuffer(
|
||||
sim.tailBuffer,
|
||||
sim.tailPartialLine,
|
||||
chunk,
|
||||
sim.tailRedrawCursor
|
||||
)
|
||||
const nextWaitState = compute(nextTail.lines, nextTail.partialLine, sim.preview)
|
||||
if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, chunk)) {
|
||||
sim.waitBlockedAt = at
|
||||
}
|
||||
sim.tailWaitState = nextWaitState
|
||||
sim.tailBuffer = nextTail.lines
|
||||
sim.tailPartialLine = nextTail.partialLine
|
||||
sim.tailRedrawCursor = nextTail.redrawCursor
|
||||
sim.preview = buildPreview(nextTail.lines, nextTail.partialLine)
|
||||
}
|
||||
|
||||
// Reference: the pre-memoization behavior — recompute the previous state fresh
|
||||
// from the current tail on every chunk (no cache).
|
||||
function stepReference(sim: TailSim, chunk: string, at: number, compute: Compute): void {
|
||||
const previousWaitState = compute(sim.tailBuffer, sim.tailPartialLine, sim.preview)
|
||||
const nextTail = appendNormalizedToTailBuffer(
|
||||
sim.tailBuffer,
|
||||
sim.tailPartialLine,
|
||||
chunk,
|
||||
sim.tailRedrawCursor
|
||||
)
|
||||
const nextWaitState = compute(nextTail.lines, nextTail.partialLine, sim.preview)
|
||||
if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, chunk)) {
|
||||
sim.waitBlockedAt = at
|
||||
}
|
||||
sim.tailBuffer = nextTail.lines
|
||||
sim.tailPartialLine = nextTail.partialLine
|
||||
sim.tailRedrawCursor = nextTail.redrawCursor
|
||||
sim.preview = buildPreview(nextTail.lines, nextTail.partialLine)
|
||||
}
|
||||
|
||||
function runBoth(chunks: string[]): { memoized: (number | null)[]; reference: (number | null)[] } {
|
||||
const memoSim = newSim()
|
||||
const refSim = newSim()
|
||||
const memoized: (number | null)[] = []
|
||||
const reference: (number | null)[] = []
|
||||
chunks.forEach((chunk, index) => {
|
||||
const at = index + 1
|
||||
stepMemoized(memoSim, chunk, at, computeTerminalTailWaitState)
|
||||
stepReference(refSim, chunk, at, computeTerminalTailWaitState)
|
||||
memoized.push(memoSim.waitBlockedAt)
|
||||
reference.push(refSim.waitBlockedAt)
|
||||
})
|
||||
return { memoized, reference }
|
||||
}
|
||||
|
||||
const SCENARIOS: Record<string, string[]> = {
|
||||
'plain output never blocks': ['building...\n', 'compiled ok\n', 'watching for changes\n'],
|
||||
'blocked prompt in one chunk': ['Update available! Press Enter to continue.\n'],
|
||||
'blocked prompt split across chunks': ['Update available!\n', 'Press Enter to continue.\n'],
|
||||
'blocked then plain output stays blocked': [
|
||||
'Update available! Press Enter to continue.\n',
|
||||
'still here\n',
|
||||
'more logs\n'
|
||||
],
|
||||
'partial lines without newline then completion': [
|
||||
'Update ava',
|
||||
'ilable! Press Enter ',
|
||||
'to continue.\n'
|
||||
],
|
||||
'empty and whitespace chunks': ['', ' ', '\n', 'ok\n', ''],
|
||||
'ready header after stale blocked prompt': [
|
||||
'Update available! Press Enter to continue.\n',
|
||||
'OpenAI Codex\n',
|
||||
'model: gpt\n',
|
||||
'directory: /repo\n'
|
||||
]
|
||||
}
|
||||
|
||||
describe('onPtyData tail wait memoization', () => {
|
||||
describe('terminal tail wait state', () => {
|
||||
it('computeTerminalTailWaitState reports fromTail and blocked signals', () => {
|
||||
const empty = computeTerminalTailWaitState([], '', '')
|
||||
expect(empty.fromTail).toBe(false)
|
||||
@@ -151,92 +34,4 @@ describe('onPtyData tail wait memoization', () => {
|
||||
lastIndexOf.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
for (const [name, chunks] of Object.entries(SCENARIOS)) {
|
||||
it(`memoized stamping matches recompute reference: ${name}`, () => {
|
||||
const { memoized, reference } = runBoth(chunks)
|
||||
expect(memoized).toEqual(reference)
|
||||
})
|
||||
}
|
||||
|
||||
it('stays equivalent across tail eviction beyond the retained cap', () => {
|
||||
const chunks: string[] = []
|
||||
for (let i = 0; i < 2600; i += 1) {
|
||||
chunks.push(`line ${i} of streaming build output that keeps the tail busy\n`)
|
||||
}
|
||||
// Introduce a real blocked prompt well past the eviction boundary.
|
||||
chunks.push('Update available! Press Enter to continue.\n')
|
||||
chunks.push('trailing log after prompt\n')
|
||||
const { memoized, reference } = runBoth(chunks)
|
||||
expect(memoized).toEqual(reference)
|
||||
// The prompt must actually be detected (guards against a vacuous match).
|
||||
expect(memoized.at(-1)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('recomputes correctly after a transcript prune empties the tail (prune-then-resume)', () => {
|
||||
// pruneDisconnectedPtyTranscript empties the tail AND clears tailWaitState;
|
||||
// model that here and assert the memoized stamping still tracks a fresh
|
||||
// recompute across the reset (a stale cache would desync the first resumed
|
||||
// chunk, since the pre-prune tail held a blocked prompt).
|
||||
const prune = (sim: TailSim): void => {
|
||||
sim.tailBuffer = []
|
||||
sim.tailPartialLine = ''
|
||||
sim.tailRedrawCursor = null
|
||||
sim.preview = ''
|
||||
sim.waitBlockedAt = null
|
||||
sim.tailWaitState = undefined
|
||||
}
|
||||
const memoSim = newSim()
|
||||
const refSim = newSim()
|
||||
const memoOut: (number | null)[] = []
|
||||
const refOut: (number | null)[] = []
|
||||
let at = 0
|
||||
const feed = (chunk: string): void => {
|
||||
at += 1
|
||||
stepMemoized(memoSim, chunk, at, computeTerminalTailWaitState)
|
||||
stepReference(refSim, chunk, at, computeTerminalTailWaitState)
|
||||
memoOut.push(memoSim.waitBlockedAt)
|
||||
refOut.push(refSim.waitBlockedAt)
|
||||
}
|
||||
// Pre-prune: leave a stale blocked prompt in the tail.
|
||||
;['building\n', 'Update available! Press Enter to continue.\n', 'more log\n'].forEach(feed)
|
||||
prune(memoSim)
|
||||
prune(refSim)
|
||||
// Resume: a fresh blocked prompt must be stamped, not masked by stale cache.
|
||||
;['fresh start\n', 'Update available! Press Enter to continue.\n', 'after\n'].forEach(feed)
|
||||
|
||||
expect(memoOut).toEqual(refOut)
|
||||
expect(memoSim.waitBlockedAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does roughly half the wait-state computations of the recompute reference', () => {
|
||||
const chunks: string[] = []
|
||||
for (let i = 0; i < 500; i += 1) {
|
||||
chunks.push(`streaming line ${i}\n`)
|
||||
}
|
||||
|
||||
let memoCalls = 0
|
||||
const countingMemo: Compute = (lines, partial, preview) => {
|
||||
memoCalls += 1
|
||||
return computeTerminalTailWaitState(lines, partial, preview)
|
||||
}
|
||||
let refCalls = 0
|
||||
const countingRef: Compute = (lines, partial, preview) => {
|
||||
refCalls += 1
|
||||
return computeTerminalTailWaitState(lines, partial, preview)
|
||||
}
|
||||
|
||||
const memoSim = newSim()
|
||||
const refSim = newSim()
|
||||
chunks.forEach((chunk, index) => {
|
||||
stepMemoized(memoSim, chunk, index + 1, countingMemo)
|
||||
stepReference(refSim, chunk, index + 1, countingRef)
|
||||
})
|
||||
|
||||
// Reference recomputes both sides every chunk: 2 per chunk.
|
||||
expect(refCalls).toBe(chunks.length * 2)
|
||||
// Memoized reuses the cached previous state on every non-empty-tail chunk:
|
||||
// 1 per chunk plus a single first-chunk cold miss.
|
||||
expect(memoCalls).toBe(chunks.length + 1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
/**
|
||||
* Regression: pruneDisconnectedPtyTranscript empties a disconnected PTY's
|
||||
* retained tail. The onPtyData wait-scan memoization caches the tail's wait
|
||||
* state on the record (tailWaitState) and reuses it as the next chunk's
|
||||
* "previous" state — so the prune MUST also clear that cache, or a record that
|
||||
* resumes output after adoption/reattach would reuse a stale (pre-prune,
|
||||
* possibly blocked) wait state and mis-stamp waitBlockedAt on its first chunk.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
// A disconnected transcript must not keep a wait-scan cache from before its history was pruned.
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import type { TerminalTailWaitState } from './orca-runtime'
|
||||
import { MAX_TAIL_LINES } from './terminal-tail-limits'
|
||||
import * as terminalWaitTailState from './terminal-wait-tail-state'
|
||||
import type { TerminalTailWaitState } from './terminal-wait-tail-state'
|
||||
|
||||
type PtyRecord = {
|
||||
connected: boolean
|
||||
@@ -20,9 +15,33 @@ type RuntimeInternals = {
|
||||
pruneDisconnectedPtyTranscript: (pty: PtyRecord) => void
|
||||
}
|
||||
|
||||
function onlyRuntimeLeaf(runtime: unknown) {
|
||||
if (typeof runtime !== 'object' || runtime === null || !('leaves' in runtime)) {
|
||||
throw new Error('Runtime has no leaves')
|
||||
}
|
||||
const leaves = runtime.leaves
|
||||
if (!(leaves instanceof Map) || leaves.size !== 1) {
|
||||
throw new Error('Expected exactly one runtime leaf')
|
||||
}
|
||||
const leaf: unknown = leaves.values().next().value
|
||||
if (
|
||||
typeof leaf !== 'object' ||
|
||||
leaf === null ||
|
||||
!('tailBuffer' in leaf) ||
|
||||
!Array.isArray(leaf.tailBuffer) ||
|
||||
!('tailLinesTotal' in leaf) ||
|
||||
typeof leaf.tailLinesTotal !== 'number' ||
|
||||
!('waitBlockedAt' in leaf)
|
||||
) {
|
||||
throw new Error('Runtime leaf has no terminal tail state')
|
||||
}
|
||||
return leaf
|
||||
}
|
||||
|
||||
describe('pruneDisconnectedPtyTranscript clears the wait-scan cache', () => {
|
||||
it('empties the tail and drops tailWaitState so resume recomputes', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both protected methods are declared on OrcaRuntimeService's inheritance chain.
|
||||
const internals = runtime as unknown as RuntimeInternals
|
||||
const pty = internals.recordPtyWorktree('pty-1', 'wt-1', { connected: true })
|
||||
|
||||
@@ -40,4 +59,65 @@ describe('pruneDisconnectedPtyTranscript clears the wait-scan cache', () => {
|
||||
expect(pty.tailBuffer).toEqual([])
|
||||
expect(pty.tailWaitState).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reuses the real leaf wait scan and stamps each newly arriving blocked prompt', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const ptyId = 'pty-memo'
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [{ tabId: 'tab-1', worktreeId: 'wt-1', title: '', activeLeafId: leafId, layout: null }],
|
||||
leaves: [
|
||||
{ tabId: 'tab-1', worktreeId: 'wt-1', leafId, paneRuntimeId: 1, ptyId, paneTitle: null }
|
||||
]
|
||||
})
|
||||
const leaf = onlyRuntimeLeaf(runtime)
|
||||
// Different retained history exercises the leaf scan rather than the shared PTY-tail path.
|
||||
leaf.tailBuffer = ['leaf-only history']
|
||||
leaf.tailLinesTotal = 1
|
||||
const scan = vi.spyOn(terminalWaitTailState, 'computeTerminalTailWaitState')
|
||||
const leafScans = () =>
|
||||
scan.mock.calls.filter(([lines]) => lines.includes('leaf-only history')).length
|
||||
try {
|
||||
runtime.onPtyData(ptyId, 'first plain line\n', 1_000)
|
||||
expect(leafScans()).toBe(2)
|
||||
expect('tailWaitState' in leaf ? leaf.tailWaitState : undefined).toMatchObject({
|
||||
fromTail: true,
|
||||
signal: null
|
||||
})
|
||||
runtime.onPtyData(ptyId, 'second plain line\n', 2_000)
|
||||
expect(leafScans()).toBe(3)
|
||||
expect(leaf.waitBlockedAt).toBeNull()
|
||||
runtime.onPtyData(ptyId, 'Update ava', 3_000)
|
||||
expect(leafScans()).toBe(4)
|
||||
expect(leaf.waitBlockedAt).toBeNull()
|
||||
runtime.onPtyData(ptyId, 'ilable! Press Enter to continue.\n', 4_000)
|
||||
expect(leafScans()).toBe(5)
|
||||
expect(leaf.waitBlockedAt).toBe(4_000)
|
||||
runtime.onPtyData(ptyId, 'ordinary log after the prompt\n', 5_000)
|
||||
expect(leafScans()).toBe(6)
|
||||
expect(leaf.waitBlockedAt).toBe(4_000)
|
||||
runtime.onPtyData(ptyId, 'Update available! Press Enter to continue.\n', 6_000)
|
||||
expect(leafScans()).toBe(7)
|
||||
expect(leaf.waitBlockedAt).toBe(6_000)
|
||||
runtime.onPtyData(
|
||||
ptyId,
|
||||
Array.from({ length: MAX_TAIL_LINES + 1 }, (_, index) => `streaming line ${index}\n`).join(
|
||||
''
|
||||
),
|
||||
7_000
|
||||
)
|
||||
expect(leaf.tailBuffer).toHaveLength(MAX_TAIL_LINES)
|
||||
expect(leaf.tailBuffer).not.toContain('leaf-only history')
|
||||
expect('tailWaitState' in leaf ? leaf.tailWaitState : undefined).toMatchObject({
|
||||
fromTail: true,
|
||||
signal: null
|
||||
})
|
||||
runtime.onPtyData(ptyId, 'Update available! Press Enter to continue.\n', 8_000)
|
||||
expect(leaf.waitBlockedAt).toBe(8_000)
|
||||
} finally {
|
||||
scan.mockRestore()
|
||||
await runtime.onPtyExit(ptyId, 0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendNormalizedToTailBuffer,
|
||||
appendNormalizedToMultilineTailBufferUnwindowed
|
||||
} from './orca-runtime'
|
||||
import { appendNormalizedToTailBuffer } from './terminal-tail-buffer'
|
||||
import { appendNormalizedToMultilineTailBufferUnwindowed } from './terminal-tail-redraw-buffer'
|
||||
|
||||
// Differential guard for the windowed redraw tail path: the public
|
||||
// appendNormalizedToTailBuffer routes vertical-control chunks through a
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TerminalCursorContext } from '../../shared/terminal-composer-draft'
|
||||
import type { HeadlessEmulator } from '../daemon/headless-emulator'
|
||||
import { projectTerminalTailLines } from './orca-runtime'
|
||||
import { projectTerminalTailLines } from './orca-runtime-terminal-projection'
|
||||
|
||||
describe('projectTerminalTailLines', () => {
|
||||
it('does not splice a scrolled viewport into unrelated tail rows', () => {
|
||||
|
||||
Reference in New Issue
Block a user