[P2] fix(runtime): stop broadcasting terminalSideEffects to clients without consumers (#11619)

* fix(runtime): stop broadcasting terminalSideEffects to clients without consumers

Co-authored-by: Orca <help@stably.ai>

* fix(runtime): keep mobile subscribers counted for side-effect availability

Excluding phones from the consumer-availability count added a new flip edge
(last desktop client leaving a phone-attached host), and the flip's tracker
rebuild cancels armed stale-working-title timers — stranding a 'working'
spinner on the phone. Availability counts all subscribers again; the
broadcast fix stays in the per-listener fan-out skip, now applied inside the
delivery callback so live-Set unsubscribe semantics and allocation-free
iteration are preserved.

Co-authored-by: Orca <help@stably.ai>

* fix(runtime): separate mobile title tracking from side-effect scans

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-30 17:58:09 -07:00
committed by GitHub
co-authored by Orca
parent 6442a9f649
commit eb35c7fa3e
5 changed files with 240 additions and 48 deletions
+70 -41
View File
@@ -2725,6 +2725,10 @@ export class OrcaRuntimeService {
private ptyController: RuntimePtyController | null = null
private notifier: RuntimeNotifier | null = null
private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>()
// Why: mobile subscribers discard terminalSideEffects; exclude them from batch delivery and production.
private terminalSideEffectExcludedClientEventListeners = new Set<
(event: RuntimeClientEvent) => void
>()
private nativeChatLaunchDraftResolutionByTabId = new Map<
string,
NativeChatLaunchDraftResolutionTombstone
@@ -4681,15 +4685,26 @@ export class OrcaRuntimeService {
}
}
onClientEvent(listener: (event: RuntimeClientEvent) => void): () => void {
onClientEvent(
listener: (event: RuntimeClientEvent) => void,
options?: { consumesTerminalSideEffects?: boolean }
): () => void {
this.clientEventListeners.add(listener)
if (options?.consumesTerminalSideEffects === false) {
this.terminalSideEffectExcludedClientEventListeners.add(listener)
}
this.refreshTerminalSideEffectConsumerAvailability()
return () => {
this.clientEventListeners.delete(listener)
this.terminalSideEffectExcludedClientEventListeners.delete(listener)
this.refreshTerminalSideEffectConsumerAvailability()
}
}
private countTerminalSideEffectConsumingClientEventListeners(): number {
return this.clientEventListeners.size - this.terminalSideEffectExcludedClientEventListeners.size
}
getTerminalSleepClientEventSnapshot(): RuntimeClientEvent[] {
const events: RuntimeClientEvent[] = []
const sleepStates = [...this.terminalSleepStateByWorktreeId.values()].sort((a, b) =>
@@ -4747,9 +4762,25 @@ export class OrcaRuntimeService {
}
private emitClientEvent(event: RuntimeClientEvent): void {
// Why: mobile streams discard terminalSideEffects; skip excluded listeners so
// paired phones never receive the per-OSC batch frames over the relay. Filtered
// inside the delivery callback to keep live-Set iteration (a listener that
// unsubscribes mid-fan-out must not be delivered to) and stay allocation-free.
const skipExcluded =
event.type === 'terminalSideEffects' &&
this.terminalSideEffectExcludedClientEventListeners.size > 0
// Why: a throwing subscriber here once escaped acquireWorktreeTerminalSpawn after it took the
// per-worktree terminal mutation, leaking it and wedging that worktree's sleep until restart.
notifyRuntimeListeners(this.clientEventListeners, (listener) => listener(event), 'client-event')
notifyRuntimeListeners(
this.clientEventListeners,
(listener) => {
if (skipExcluded && this.terminalSideEffectExcludedClientEventListeners.has(listener)) {
return
}
listener(event)
},
'client-event'
)
}
notifyNativeChatLaunchDraftResolved(
@@ -9417,7 +9448,7 @@ export class OrcaRuntimeService {
console.error('[runtime] terminal side-effect listener threw', { ptyId, err })
}
}
if (this.clientEventListeners.size > 0) {
if (this.countTerminalSideEffectConsumingClientEventListeners() > 0) {
this.emitClientEvent({ type: 'terminalSideEffects', batch })
}
}
@@ -9585,33 +9616,23 @@ export class OrcaRuntimeService {
this.retirePtyAgentLaunchAuthority(ptyId)
this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode })
},
// Why: headless serve still scans command completion to retire agent
// launch authority; other transient facts remain desktop-only.
...(this.terminalSideEffectConsumerAvailable
? {
onBell: () => {
this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' })
},
onPrLink: (link: TerminalGitHubPRLink) => {
this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link })
},
// Why: hidden-delivery-gated views never see the bytes, so main
// surfaces DECSET 2031 subscribes as facts; the theme reply is
// still sent by the renderer (query authority stays with the view).
onMode2031Subscribe: () => {
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' })
},
// Why: the gated view never sees the withdrawal bytes either, so the
// subscription registry it keeps for theme flips needs this fact to
// stay truthful.
onMode2031Unsubscribe: () => {
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' })
}
}
: {})
onBell: () => {
this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' })
},
onPrLink: (link: TerminalGitHubPRLink) => {
this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link })
},
// Why: hidden-delivery-gated views never see 2031 bytes; facts keep their theme registry truthful.
onMode2031Subscribe: () => {
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' })
},
onMode2031Unsubscribe: () => {
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' })
}
},
initialTitle !== null ? { initialTitle } : {}
)
tracker.setTransientSideEffectScanningEnabled(this.terminalSideEffectConsumerAvailable)
const entry: RuntimePtyTitleTrackerEntry = {
tracker,
applyingChunk: false,
@@ -9624,15 +9645,7 @@ export class OrcaRuntimeService {
// self-arms on the Command Code banner; the spawn command (when main
// saw one) mirrors the renderer detector's startupCommand fast-arm.
commandCodeDetector: this.terminalSideEffectConsumerAvailable
? createCommandCodeOutputStatusDetector({
startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null,
onWorking: (prompt) => {
this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt })
},
onDone: (prompt) => {
this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt })
}
})
? this.createTerminalSideEffectCommandCodeDetector(ptyId)
: null
}
this.ptyTitleTrackersByPtyId.set(ptyId, entry)
@@ -9752,18 +9765,34 @@ export class OrcaRuntimeService {
private refreshTerminalSideEffectConsumerAvailability(): void {
const nextAvailable =
this.terminalSideEffectLocalConsumerAvailable || this.clientEventListeners.size > 0
this.terminalSideEffectLocalConsumerAvailable ||
this.countTerminalSideEffectConsumingClientEventListeners() > 0
if (nextAvailable === this.terminalSideEffectConsumerAvailable) {
return
}
this.terminalSideEffectConsumerAvailable = nextAvailable
// Why: optional bell/command/link scanners are selected when a tracker is
// created. Rebuild at the window boundary so pure headless output stays cheap.
for (const ptyId of [...this.ptyTitleTrackersByPtyId.keys()]) {
this.disposePtyTitleTracker(ptyId)
for (const [ptyId, entry] of this.ptyTitleTrackersByPtyId) {
entry.tracker.setTransientSideEffectScanningEnabled(nextAvailable)
entry.commandCodeDetector = nextAvailable
? this.createTerminalSideEffectCommandCodeDetector(ptyId)
: null
}
}
private createTerminalSideEffectCommandCodeDetector(
ptyId: string
): NonNullable<RuntimePtyTitleTrackerEntry['commandCodeDetector']> {
return createCommandCodeOutputStatusDetector({
startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null,
onWorking: (prompt) => {
this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt })
},
onDone: (prompt) => {
this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt })
}
})
}
private extractLastOsc7CwdForPty(
ptyId: string,
data: string