mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
[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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user