mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 00:02:34 +00:00
test(runtime): retire the handle-gap assertions that could not fail
`returns to baseline under churn across all three drains` asserted nothing. It ran 300 expiries and then cleared every environment's verdicts by name before counting, so the map was empty by construction — deleting the whole prune loop in `recordExpiredWait` left the test green. It now asserts the bound BEFORE the teardown clear: 300 expiries must leave exactly one live verdict per environment. It also binds each round's pane to the environment recording it; the old fixture filed every binding under env-a, so two rounds in three stored the empty match value the read-time check refuses, and that much of the churn was synthetic. Renamed: there are four drains, not three. Two comments described outcomes their assertions do not produce. `c1` reads false on the read-time generation gate alone, whether or not a drain ever swept it — the count below is the only assertion that distinguishes retired from stranded. And the discriminator in `never evicts a live pane verdict` is env-b, whose row goes absent while env-a records; env-c is a control that holds under every candidate rule. Two more fixtures modelled states the mirror apply cannot produce, both leaning on `ptyIdsByTabId[tab]` holding a PTY id no leaf of that tab is bound to. It builds one from the other (web-session-tabs-sync/terminal-build.ts), so they can never disagree. The producible shape is a SPLIT tab whose sibling surface went `ready` first, which is what both cases now seed — and which makes the residual they were quietly standing in for visible instead: the decidability gate above this wait is tab-granular while everything below it is leaf-aware, so a sibling handle ends the wait for a surface still `pending-handle`. Recorded at the gate in host-mirrored-pane-liveness.ts, pinned by name, and left open here: it needs the per-surface status the host already publishes and the client drops on apply. Also states what the unscoped live-PTY arm trades — a finished agent whose shell is still up releases its record and will not auto-resume — because it reads as a regression and is not one. And replaces the subscription-lifetime header's unreproducible "272 tests across 33 suites" with the measured 326 across 37.
This commit is contained in:
@@ -36,6 +36,7 @@ const initialAppStoreState = useAppStore.getState()
|
||||
const LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const WEB_TAB_ID = 'web-terminal-host-tab-1'
|
||||
const SECOND_LEAF_ID = '33333333-3333-4333-8333-333333333333'
|
||||
const SIBLING_LEAF_ID = '44444444-4444-4444-8444-444444444444'
|
||||
const SECOND_TAB_ID = 'web-terminal-host-tab-2'
|
||||
const RUNTIME_ENV_ID = 'env-handle-gap'
|
||||
|
||||
@@ -87,6 +88,40 @@ function seedMirroredWorkspace(worktree: ReturnType<typeof makeCreatedAgentWorkt
|
||||
useAppStore.setState(state as AppState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebinds the published tab as a SPLIT whose sibling leaf is `ready` and whose record-bearing leaf
|
||||
* is still `pending-handle`.
|
||||
*
|
||||
* Why this shape and not "the tab published a handle nobody's leaf is bound to": the mirror builds
|
||||
* `ptyIdsByTabId[tab]` out of the very map it writes to `terminalLayoutsByTabId[tab].ptyIdsByLeafId`
|
||||
* (web-session-tabs-sync/terminal-build.ts), so those two can never disagree about which PTY ids
|
||||
* exist. The only producible way for a tab to hold handles while THIS pane holds none is a split
|
||||
* whose other surface went `ready` first — a per-surface state the host projects
|
||||
* (runtime-mobile-session-projection.ts) and the client does not keep.
|
||||
*/
|
||||
function seedPendingLeafBesideReadySibling(): void {
|
||||
const before = useAppStore.getState()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
|
||||
useAppStore.setState({
|
||||
terminalLayoutsByTabId: {
|
||||
...before.terminalLayoutsByTabId,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture carries the fields this suite drives; the cast only supplies the rest of the declared shape.
|
||||
[WEB_TAB_ID]: {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'row',
|
||||
first: { type: 'leaf', leafId: LEAF_ID },
|
||||
second: { type: 'leaf', leafId: SIBLING_LEAF_ID }
|
||||
},
|
||||
activeLeafId: SIBLING_LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
// The record's leaf is absent: pending-handle with no prior binding to retain.
|
||||
ptyIdsByLeafId: { [SIBLING_LEAF_ID]: 'remote:env-handle-gap@@term_sibling' }
|
||||
} as never
|
||||
}
|
||||
} as never)
|
||||
}
|
||||
|
||||
/** A second published mirrored row in the same environment, with its own leaf binding. */
|
||||
function seedSecondMirroredPane(worktreeId: string): void {
|
||||
const before = useAppStore.getState()
|
||||
@@ -184,14 +219,31 @@ describe('resume across the mirror handle gap', () => {
|
||||
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1)
|
||||
})
|
||||
|
||||
it('still resumes once the host has published the row without any live handle', () => {
|
||||
// The counterweight to the park, and the reason the hydration short-circuit could not simply be
|
||||
// dropped: a pane with nothing outstanding must still resume. Here no leaf of the published row
|
||||
// binds a PTY this environment minted, so there is no handle on its way and no wait to arm —
|
||||
// parking would be the latch-that-never-releases defect, since mirror settlement has already run
|
||||
// and will not replay the sweep a second time.
|
||||
it('still resumes a published row no leaf of which binds this environment', () => {
|
||||
const worktree = makeRuntimeOwnedWorktree()
|
||||
seedMirroredWorkspace(worktree)
|
||||
seedActiveSleepingRecord(worktree.id)
|
||||
useAppStore.setState({ ptyIdsByTabId: { [WEB_TAB_ID]: ['remote:env-handle-gap@@other'] } })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seeded slice names only the store fields this suite drives; the rest of AppState keeps its defaults.
|
||||
useAppStore.setState({
|
||||
terminalLayoutsByTabId: {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture carries the fields this suite drives; the cast only supplies the rest of the declared shape.
|
||||
[WEB_TAB_ID]: {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {}
|
||||
} as never
|
||||
}
|
||||
} as never)
|
||||
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
|
||||
|
||||
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(1)
|
||||
expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0)
|
||||
})
|
||||
|
||||
// The three exits of the per-pane park. A park with no bounded release is the
|
||||
@@ -215,14 +267,29 @@ describe('resume across the mirror handle gap', () => {
|
||||
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
|
||||
})
|
||||
|
||||
it('releases when a handle lands for the tab and resumes if it belongs to another pane', () => {
|
||||
// KNOWN RESIDUAL, pinned as current behaviour rather than as desired behaviour. The gate this
|
||||
// wait sits behind is tab-granular (`host-mirrored-pane-liveness.ts`: any published handle for
|
||||
// the tab makes the pane decidable), while everything below it is leaf-aware. In a SPLIT mirrored
|
||||
// tab a sibling surface reaching `ready` therefore ends the wait for a surface that is still
|
||||
// `pending-handle`, and the sweep resumes it — #19735's own shape, narrowed to split tabs.
|
||||
//
|
||||
// It is not closable inside this module: during the gap the pane's own leaf has NO binding, and
|
||||
// the binding is what names the pane in a verdict, so a leaf-keyed wait has nothing to key on.
|
||||
// The client has to stop discarding each surface's `pending-handle` status
|
||||
// (runtime-mobile-session-projection.ts publishes it; terminal-build.ts keeps only `ready`
|
||||
// leaves' bindings). Tracked separately; this case exists so the residual cannot be mistaken for
|
||||
// a covered one.
|
||||
it('resumes a pending leaf once a sibling leaf of the same tab publishes its handle', () => {
|
||||
const worktree = makeRuntimeOwnedWorktree()
|
||||
seedMirroredWorkspace(worktree)
|
||||
const paneKey = seedActiveSleepingRecord(worktree.id)
|
||||
markHostSessionMirrorHydrated(RUNTIME_ENV_ID)
|
||||
expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0)
|
||||
|
||||
useAppStore.setState({ ptyIdsByTabId: { [WEB_TAB_ID]: ['remote:env-handle-gap@@other'] } })
|
||||
seedPendingLeafBesideReadySibling()
|
||||
useAppStore.setState({
|
||||
ptyIdsByTabId: { [WEB_TAB_ID]: ['remote:env-handle-gap@@term_sibling'] }
|
||||
})
|
||||
|
||||
const after = useAppStore.getState()
|
||||
const resumeTabIds = (after.tabsByWorktree[worktree.id] ?? [])
|
||||
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
// - RETAIN direction (drop the verdict term from `stopStoreSubscriptionIfIdle`, so a verdict
|
||||
// with no waiter behind it loses the subscription its drain needs): already caught, by
|
||||
// host-mirror-handle-gap-landed-handle.test.ts. Two failures there without this file.
|
||||
// - RELEASE direction (never release the subscription at all): caught by NOTHING. That mutation
|
||||
// passes all 272 tests across the 33 other handle-gap and session-tabs suites. A leaked
|
||||
// - RELEASE direction (never release the subscription at all): caught by NOTHING else. With
|
||||
// `stopStoreSubscriptionIfIdle` neutered, the three cases below are the only failures in the
|
||||
// handle-gap and session-tabs tree: 326 tests across the other 37 files still pass. A leaked
|
||||
// subscription rescans every parked pane on every store write for the life of the session and
|
||||
// no test notices.
|
||||
// nothing else notices.
|
||||
//
|
||||
// So this file exists for the release direction; the retain cases are here because the two belong
|
||||
// in one place, not because they were missing. `stopStoreSubscriptionIfIdle` counts VERDICTS as
|
||||
|
||||
@@ -118,7 +118,9 @@ describe('handle-gap verdict map, all rules on one tree', () => {
|
||||
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'a1')).toBe(false)
|
||||
// B drained: by teardown, which is the only trigger that fires for a removed environment.
|
||||
expect(hasHostMirrorHandleWaitExpired(ENV_B, 'b1')).toBe(false)
|
||||
// C drained: env-a's expiry retired env-c's superseded row, though env-c never expired again.
|
||||
// C: env-c reconnected at :109, so this read is false on the read-time generation gate alone
|
||||
// and says nothing about whether the drain ran. The drain is what the COUNT below proves — it
|
||||
// is the only assertion here that distinguishes "retired" from "stranded but unreachable".
|
||||
expect(hasHostMirrorHandleWaitExpired(ENV_C, 'c1')).toBe(false)
|
||||
|
||||
// D is closed, and NOT by a prune. No trigger any rule above owns fires at the right moment:
|
||||
@@ -191,9 +193,10 @@ describe('handle-gap verdict map, all rules on one tree', () => {
|
||||
})
|
||||
|
||||
it('never evicts a live pane verdict, whichever environment sweeps', () => {
|
||||
// Three environments on purpose: with two at one generation the candidate rules are
|
||||
// indistinguishable and the naive "judge everything against the recording environment"
|
||||
// mutation survives. env-c is the discriminator — its verdict is live.
|
||||
// env-b is the discriminator, and it is the only assertion here that is not a control: its row
|
||||
// goes absent at the moment env-a records, so widening the tab-death rule past the recording
|
||||
// environment deletes a verdict whose pane is merely mid-republish. env-a's and env-c's rows
|
||||
// are published throughout and hold under every candidate rule.
|
||||
for (const environmentId of [ENV_A, ENV_B, ENV_C]) {
|
||||
setRuntimeEnvironmentConnectionGenerationForTests(environmentId, 1)
|
||||
}
|
||||
@@ -213,13 +216,24 @@ describe('handle-gap verdict map, all rules on one tree', () => {
|
||||
expect(hasHostMirrorHandleWaitExpired(ENV_A, 'a1')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns to baseline under churn across all three drains', () => {
|
||||
it('holds the verdict map at one live row per environment under churn', () => {
|
||||
for (let round = 0; round < 300; round += 1) {
|
||||
const environmentId = [ENV_A, ENV_B, ENV_C][round % 3]!
|
||||
setRuntimeEnvironmentConnectionGenerationForTests(environmentId, round + 1)
|
||||
setLiveTabs([`tab-${round}`])
|
||||
// Bind each round's pane to the environment that is recording it. Falling back to env-a for
|
||||
// every round would store `paneBinding: ''` on two rounds in three, which is the match value
|
||||
// the read-time check refuses — two thirds of the churn would then be synthetic.
|
||||
setLiveTabs([`tab-${round}`], { [`tab-${round}`]: `remote:${environmentId}@@term_${round}` })
|
||||
parkAndExpire(environmentId, `tab-${round}`)
|
||||
}
|
||||
|
||||
// The assertion the loop exists for, and it has to come BEFORE teardown: the clear below
|
||||
// deletes every key in the map by construction, so `toBe(0)` after it holds whether the drains
|
||||
// work or are deleted outright. 300 expiries must leave one live verdict per environment —
|
||||
// each environment's own generation bump retires its predecessors, and the two environments
|
||||
// not recording keep exactly their latest row.
|
||||
expect(countHostMirrorHandleGapVerdictsForTests()).toBe(3)
|
||||
|
||||
for (const environmentId of [ENV_A, ENV_B, ENV_C]) {
|
||||
clearHostMirrorHandleGapVerdictsForEnvironment(environmentId)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,19 @@ export function findUnhydratedHostMirrorForPane(
|
||||
}
|
||||
// Why: a published PTY handle for the tab is the mirror having spoken for it,
|
||||
// whatever the individual leaf's fate.
|
||||
//
|
||||
// TAB-GRANULAR, and everything below this line is leaf-aware — the asymmetry is a known residual,
|
||||
// not an oversight. For a single-leaf tab (every agent tab Orca creates) it is exact: the mirror
|
||||
// builds `ptyIdsByTabId[tab]` out of the same map it writes to the layout's `ptyIdsByLeafId`
|
||||
// (web-session-tabs-sync/terminal-build.ts), so a non-empty entry means this leaf is bound and
|
||||
// live. For a SPLIT mirrored tab it is not: a sibling surface reaching `ready` first ends the
|
||||
// wait for one still `pending-handle`, and the resume fires — #19735 narrowed to split tabs.
|
||||
// It cannot be closed here, because during the gap the pane's own leaf holds no binding and the
|
||||
// binding is what names a pane in a handle-gap verdict. Closing it means keeping each surface's
|
||||
// `pending-handle` status, which the host already publishes
|
||||
// (main/runtime/runtime-mobile-session-projection.ts) and the client drops on apply.
|
||||
// Pinned as current behaviour by "resumes a pending leaf once a sibling leaf of the same tab
|
||||
// publishes its handle" in host-mirror-handle-gap-resume.test.ts.
|
||||
if ((state.ptyIdsByTabId[tabId]?.length ?? 0) > 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -177,7 +177,10 @@ describe('resume sleeping agent provider claims', () => {
|
||||
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not fork a provider session a live pane in another workspace is running', () => {
|
||||
// The load-bearing half of the pair: this is the only case that proves the live arm carries no
|
||||
// workspace scope. The peer is `done` here too — a finished turn on a pane whose shell is still
|
||||
// up — so "live" means the PTY, not the agent.
|
||||
it('does not fork a provider session a live pane in another workspace already finished a turn on', () => {
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
const peerPaneKey = makePaneKey('tab-peer', OTHER_LEAF_ID)
|
||||
const record = makeRecord(paneKey)
|
||||
|
||||
@@ -117,6 +117,13 @@ function activeOrQueuedResumeClaimsProviderSession(
|
||||
// orphaned terminal re-keys `tabsByWorktree` without re-keying the sleeping records that name
|
||||
// the old id (workspace-session-worktree-id.ts), and a completed turn on a live pane is exactly
|
||||
// where the drift stops being caught.
|
||||
// What this trades, stated because it reads as a regression: `entry.state` is ignored, so a
|
||||
// FINISHED agent whose shell is still up releases its record and will not auto-resume. That is
|
||||
// the intended side of the trade, not an oversight. A live PTY is positive evidence the host
|
||||
// holds the transcript, and a bare `done` row cannot be told apart from a REPL idling at its
|
||||
// prompt with the process still attached. Nothing is killed: the pane, its shell and the
|
||||
// transcript survive, the record was only a queued respawn, and the user can resume by hand.
|
||||
// Forking the transcript is not recoverable; declining to auto-resume is.
|
||||
if (
|
||||
pane &&
|
||||
tabId === pane.tabId &&
|
||||
|
||||
Reference in New Issue
Block a user