diff --git a/src/main/daemon/AGENTS.md b/src/main/daemon/AGENTS.md index 6cb0ffa9af5..66a6d291aae 100644 --- a/src/main/daemon/AGENTS.md +++ b/src/main/daemon/AGENTS.md @@ -31,9 +31,13 @@ hands → probe once more → `rename` in one syscall → verify we kept it. replacing it. Only the daemon itself can prove it is empty, over IPC; the process table may only ever *raise* a verdict toward "occupied", never lower one toward a kill. - Two exclusions, both about never holding something unrecoverable: an endpoint that is proven - dead (a cold start has nothing to hold), and `rejected` (it answered and refused, so it can - never be adopted and its sessions can never be reattached). + Two exclusions apply **to that residual only** — not to a daemon already proven occupied: + an endpoint that is proven dead (a cold start has nothing to hold), and `rejected` (it + answered and refused, so it can never be adopted and its sessions can never be reattached). + + A `rejected` daemon that process evidence shows *is* hosting live PTYs is still held, because + the choice there is between unreachable-but-running agents and dead ones. Restart recovers it + at the documented cost. The cost is deliberate and known: a wedged-but-empty daemon is no longer replaced at launch, so #8689 degrades to "restart it from Manage Sessions" instead of being handled automatically. diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 14516798ce3..d1d6d973e13 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- Why: covers daemon-init's full restart flow (7-step sequence per docs/daemon-staleness-ux.md §Phase 1 + coalescer); one describe block keeps shared mocks in one place. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DaemonLaunchMode } from './daemon-spawner' import { join } from 'node:path' import { PROTOCOL_VERSION } from './types' import { WEDGED_DAEMON_CLASSIFICATION_BUDGET_MS, WEDGED_DAEMON_GRACE_RETRIES } from './daemon-init' @@ -135,10 +136,12 @@ const { // Why: every DaemonSpawner pushes here so assertions can check the *same* spawner was reused across restart. const spawnerInstances: MockSpawner[] = [] + // Mirrors DaemonLaunchMode rather than restating one of its members: the type used to omit + // 'held', so no test could describe the launch the hold produces. const ensureRunningOverrides: (() => Promise<{ socketPath: string tokenPath: string - mode?: 'degraded-new-pty-fallback' + mode?: DaemonLaunchMode }>)[] = [] const adoptionLeaseReleases: ReturnType[] = [] const lifecycleLeaseErrors: Error[] = [] @@ -343,7 +346,7 @@ vi.mock('./daemon-spawner', () => ({ readonly getHandle: ReturnType private socketCounter: number private handle: { - mode?: 'degraded-new-pty-fallback' + mode?: DaemonLaunchMode releaseAdoptionLease?: () => void shutdown: () => Promise } | null @@ -755,6 +758,28 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce() }) + it('routes a held daemon through the degraded provider, not a bare adapter', async () => { + // The wiring the hold depends on. Without it a held daemon gets a plain DaemonPtyAdapter: + // every fresh spawn hangs on the wedged socket instead of falling back locally, and + // isDaemonDegraded() — an instanceof check — reports false, so the notice telling the user + // how to recover never renders. + const mod = await importFresh() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/held-socket', + tokenPath: '/fake/held-token', + mode: 'held' + })) + + await mod.initDaemonPtyProvider() + + const { DegradedDaemonPtyProvider } = await import('./degraded-daemon-pty-provider') + const provider = mod.getDaemonProvider() + expect(provider).toBeInstanceOf(DegradedDaemonPtyProvider) + expect( + (provider as InstanceType).routesFreshSpawnsToLocalProvider + ).toBe(true) + }) + it('routes fresh PTYs to the local fallback when a preserved daemon cannot spawn new PTYs', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ @@ -2953,7 +2978,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { socketPath: string, tokenPath: string ) => Promise<{ - mode?: 'degraded-new-pty-fallback' + mode?: DaemonLaunchMode shutdown(): Promise }> checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy') diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 533106a6fdd..a5f3c45a60f 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -102,16 +102,24 @@ export const WEDGED_DAEMON_GRACE_RETRIES = 11 export const WEDGED_DAEMON_CLASSIFICATION_BUDGET_MS = 34_000 /** - * Held back from the probes for the two steps that follow them inside the same ceiling: the - * identity re-check and the process-table read. Without it the loop would spend the clock to - * the last millisecond and those two would run past it — which is precisely how the previous - * budget kept overrunning, one unremembered term at a time. + * The clock the identity re-check and process-table read need, checked *after* the probes + * rather than withheld from them. * - * Zero on Windows, which runs neither step: it has no process-table evidence to read and so - * no identity to verify for it. Reserving there would hold back time from the probes that are - * the only thing standing between a wedged Windows daemon and a kill. + * It used to be a reservation, and that was backwards once 'unknown' stopped being lethal. + * Evidence can only raise 'unknown' to an uncounted 'occupied', and both of those now hold the + * daemon — so the read changes the wording of a warning and nothing else. Withholding twelve + * seconds for it starved the one probe whose answer can still restore full daemon mode: a + * counted reply is what reaches preserveDaemon(). With a reservation the "patient" ask + * resolved to exactly the cheap ask's four seconds and the grace loop could never afford to + * run at all. + * + * So the evidence read is opportunistic now. If the probes used the clock, it is skipped and + * the verdict stays 'unknown' — which holds the daemon exactly as an evidence-raised + * 'occupied' would have. Nothing is lost but a more precise log line. + * + * Zero on Windows, which runs neither step. */ -export const CLASSIFICATION_EVIDENCE_RESERVE_MS = process.platform === 'win32' ? 0 : 12_000 +export const CLASSIFICATION_EVIDENCE_MIN_MS = process.platform === 'win32' ? 0 : 12_000 const DAEMON_SELF_SHUTDOWN_WAIT_MS = 5_000 const DAEMON_CHILD_TERMINATION_GRACE_MS = 5_000 const DAEMON_CHILD_FORCE_EXIT_WAIT_MS = 1_000 @@ -638,8 +646,7 @@ function createOutOfProcessLauncher( // the launch budget for an answer we already have. It is read once, after the wait. // Every probe spends the shared clock, minus what the evidence read still needs, so no // probe can eat the reserve however long the daemon takes to answer. - const probeBudgetMs = (): number => - classificationRemainingMs() - CLASSIFICATION_EVIDENCE_RESERVE_MS + const probeBudgetMs = (): number => classificationRemainingMs() const askDaemonWhatItHosts = (connectBudgetMs: number): Promise => resolveDaemonOccupancy({ socketPath, @@ -678,7 +685,9 @@ function createOutOfProcessLauncher( // to be re-verified first: the grace window is long enough for the daemon to die and // its pid to be recycled, and the evidence would then describe a stranger's children. const evidencePid = - occupancy.state === 'unknown' && process.platform !== 'win32' + occupancy.state === 'unknown' && + process.platform !== 'win32' && + classificationRemainingMs() >= CLASSIFICATION_EVIDENCE_MIN_MS ? ((await readVerifiedDaemonPid(runtimeDir, socketPath, tokenPath))?.pid ?? null) : null occupancy = await raiseOccupancyWithProcessEvidence(occupancy, evidencePid) diff --git a/src/main/daemon/daemon-launch-budget.test.ts b/src/main/daemon/daemon-launch-budget.test.ts index c9813c68182..a3d4c2ffcaa 100644 --- a/src/main/daemon/daemon-launch-budget.test.ts +++ b/src/main/daemon/daemon-launch-budget.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS } from '../startup/first-window-startup-services' import { - CLASSIFICATION_EVIDENCE_RESERVE_MS, + CLASSIFICATION_EVIDENCE_MIN_MS, WEDGED_DAEMON_CLASSIFICATION_BUDGET_MS } from './daemon-init' import { OCCUPANCY_CONNECT_BUDGET_MS, OCCUPANCY_REQUEST_BUDGET_MS } from './daemon-occupancy' @@ -9,7 +9,7 @@ import { POSIX_OWNERSHIP_PROBE_DEADLINE_MS, PTY_OWNERSHIP_PROBE_ATTEMPTS } from './daemon-live-pty-evidence' -import { PS_IDENTITY_TIMEOUT_MS } from './daemon-health' +import { HEALTH_CHECK_TIMEOUT_MS, PS_IDENTITY_TIMEOUT_MS } from './daemon-health' /** * Kept out of the launcher's own spec because that file mocks daemon-health, which would @@ -39,27 +39,46 @@ describe('wedged-daemon classification budget', () => { expect(afterClassificationMs).toBeGreaterThanOrEqual(22_000) }) - it('reserves enough of the clock for the steps that run after the probes', () => { - // The launcher holds CLASSIFICATION_EVIDENCE_RESERVE_MS back from every probe so the - // identity re-check and the process-table read still fit inside the same ceiling. Assert - // the reserve is actually big enough for them — the reserve existing is not the same as - // the reserve being sufficient, and "big enough" is the part that silently rots. + it('leaves the evidence read enough clock to be worth attempting', () => { + // Not a reservation: the probes spend first and this is checked afterwards. Assert only + // that the threshold covers what the two steps actually cost, or the launcher would start + // a read it cannot finish. const evidenceMs = POSIX_OWNERSHIP_PROBE_DEADLINE_MS * PTY_OWNERSHIP_PROBE_ATTEMPTS if (process.platform === 'win32') { - // Neither guarded step runs on Windows — there is no session-leader signal to read, so - // the launcher skips both the identity probe and the evidence read. Reserving clock for - // work that never happens would only shorten the probes that protect live sessions. - expect(CLASSIFICATION_EVIDENCE_RESERVE_MS).toBe(0) + // Neither guarded step runs on Windows — there is no session-leader signal to read. + expect(CLASSIFICATION_EVIDENCE_MIN_MS).toBe(0) } else { - expect(CLASSIFICATION_EVIDENCE_RESERVE_MS).toBeGreaterThanOrEqual( + expect(CLASSIFICATION_EVIDENCE_MIN_MS).toBeGreaterThanOrEqual( PS_IDENTITY_TIMEOUT_MS + evidenceMs ) } - // And a probe must still be fundable once the reserve is set aside, or the grace loop - // would never run at all. - expect( - WEDGED_DAEMON_CLASSIFICATION_BUDGET_MS - CLASSIFICATION_EVIDENCE_RESERVE_MS - ).toBeGreaterThanOrEqual(OCCUPANCY_CONNECT_BUDGET_MS + OCCUPANCY_REQUEST_BUDGET_MS) + }) + + it('gives the patient ask more clock than the cheap ask it replaced', () => { + // The defect this exists to catch shipped once: with the evidence clock withheld from the + // probes, this expression resolved to exactly OCCUPANCY_CONNECT_BUDGET_MS, so the "patient" + // ask was a cheap ask with a comment claiming otherwise. Every launcher test still passed, + // because mocked probes resolve instantly and the budget never binds under a frozen clock. + const elapsedBeforeAsk = OCCUPANCY_CONNECT_BUDGET_MS + HEALTH_CHECK_TIMEOUT_MS + const probeBudgetMs = WEDGED_DAEMON_CLASSIFICATION_BUDGET_MS - elapsedBeforeAsk + const patientConnectMs = Math.max( + OCCUPANCY_CONNECT_BUDGET_MS, + probeBudgetMs - OCCUPANCY_REQUEST_BUDGET_MS + ) + + expect(patientConnectMs).toBeGreaterThan(OCCUPANCY_CONNECT_BUDGET_MS) + }) + + it('can still afford a grace retry after an ask that fails fast', () => { + // The same shortage killed the retry loop outright: the gate needs connect+request, and + // with the reservation in place it could never hold after the first ask. A loop that cannot + // run is worse than no loop, because WEDGED_DAEMON_GRACE_RETRIES then documents a patience + // the launcher does not have. + const elapsedAfterAFastAsk = OCCUPANCY_CONNECT_BUDGET_MS + HEALTH_CHECK_TIMEOUT_MS + 5_000 + + expect(WEDGED_DAEMON_CLASSIFICATION_BUDGET_MS - elapsedAfterAFastAsk).toBeGreaterThanOrEqual( + OCCUPANCY_CONNECT_BUDGET_MS + OCCUPANCY_REQUEST_BUDGET_MS + ) }) }) diff --git a/src/main/daemon/degraded-daemon-fresh-spawn-routing.test.ts b/src/main/daemon/degraded-daemon-fresh-spawn-routing.test.ts new file mode 100644 index 00000000000..ca2e7e5bd49 --- /dev/null +++ b/src/main/daemon/degraded-daemon-fresh-spawn-routing.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' +import { + DEGRADED_DAEMON_RECOVERY_RETRY_MS, + DegradedDaemonFreshSpawnRouter +} from './degraded-daemon-fresh-spawn-routing' +import type { IPtyProvider, PtySpawnResult } from '../providers/types' + +function provider(id: string, spawn?: IPtyProvider['spawn']): IPtyProvider { + return { + spawn: spawn ?? vi.fn(async () => ({ id: `${id}-pty` }) as PtySpawnResult) + } as unknown as IPtyProvider +} + +function router(opts: { + probe?: (() => Promise) | null + currentSpawn?: IPtyProvider['spawn'] +}) { + const current = provider('current', opts.currentSpawn) + const fallback = provider('fallback') + const sessionProviders = new Map() + return { + current, + fallback, + sessionProviders, + router: new DegradedDaemonFreshSpawnRouter( + current, + fallback, + sessionProviders, + opts.probe === undefined ? async () => true : opts.probe + ) + } +} + +describe('DegradedDaemonFreshSpawnRouter', () => { + it('starts on the fallback, so a held daemon never receives a fresh spawn', () => { + expect(router({}).router.routesToFallback).toBe(true) + }) + + it('promotes fresh spawns back to the daemon once it answers a health check', async () => { + const { router: r } = router({ probe: async () => true }) + + await expect(r.recover()).resolves.toBe(true) + expect(r.routesToFallback).toBeUndefined() + }) + + it('stays on the fallback while the daemon is still unhealthy', async () => { + const { router: r } = router({ probe: async () => false }) + + await expect(r.recover()).resolves.toBe(false) + expect(r.routesToFallback).toBe(true) + }) + + it('routes back to the fallback when a spawn fails after recovery', async () => { + // The defect: recovery was a one-way flip on a two-way condition. A daemon that answers one + // health check and wedges again kept every later fresh spawn pointed at it, and each one + // costs a hello timeout plus a full launcher re-classification — per terminal, for the rest + // of the session. + const wedged = vi.fn(async () => { + throw new Error('Hello response timed out') + }) + const { router: r } = router({ probe: async () => true, currentSpawn: wedged }) + + await r.recover() + expect(r.routesToFallback).toBeUndefined() + + await expect(r.spawn({ cwd: '/tmp' } as never)).rejects.toThrow('Hello response timed out') + expect(r.routesToFallback).toBe(true) + }) + + it('does not immediately re-promote after routing back', async () => { + // Without re-arming the cooldown the next spawn probes again straight away, and a wedged + // daemon that still passes a cheap health check would be re-promoted into the same failure. + const wedged = vi.fn(async () => { + throw new Error('Hello response timed out') + }) + const { router: r } = router({ probe: async () => true, currentSpawn: wedged }) + + await r.recover() + await expect(r.spawn({ cwd: '/tmp' } as never)).rejects.toThrow() + + await expect(r.recover()).resolves.toBe(false) + expect(r.routesToFallback).toBe(true) + + const past = vi + .spyOn(Date, 'now') + .mockReturnValue(Date.now() + DEGRADED_DAEMON_RECOVERY_RETRY_MS + 1) + try { + await expect(r.recover()).resolves.toBe(true) + } finally { + past.mockRestore() + } + }) + + it('leaves an explicitly mapped session routed where it is', async () => { + // A mapped id names the provider that actually owns that pty. Rerouting on its failure would + // send a daemon-owned session to a provider that does not have it. + const wedged = vi.fn(async () => { + throw new Error('Hello response timed out') + }) + const { + router: r, + current, + sessionProviders + } = router({ + probe: async () => true, + currentSpawn: wedged + }) + sessionProviders.set('session-1', current) + // Promote first, or the assertion below passes on the constructor's default and proves nothing. + await r.recover() + expect(r.routesToFallback).toBeUndefined() + + await expect(r.spawn({ cwd: '/tmp', sessionId: 'session-1' } as never)).rejects.toThrow() + expect(r.routesToFallback).toBeUndefined() + }) +}) diff --git a/src/main/daemon/degraded-daemon-fresh-spawn-routing.ts b/src/main/daemon/degraded-daemon-fresh-spawn-routing.ts index b1d4661dc93..fdb700e046e 100644 --- a/src/main/daemon/degraded-daemon-fresh-spawn-routing.ts +++ b/src/main/daemon/degraded-daemon-fresh-spawn-routing.ts @@ -68,7 +68,24 @@ export class DegradedDaemonFreshSpawnRouter { async spawn(opts: PtySpawnOptions): Promise { const mapped = opts.sessionId ? this.sessionProviders.get(opts.sessionId) : undefined const target = mapped ?? this.target - const result = await target.spawn(opts) + let result: PtySpawnResult + try { + result = await target.spawn(opts) + } catch (error) { + // Why route back: recovery was a one-way flip on a two-way condition. A daemon that + // answers one health check and wedges again kept every later spawn pointed at it, and a + // spawn there costs a hello timeout plus a full launcher re-classification — per terminal, + // for the rest of the session. Sending the next one to the fallback costs a terminal + // without daemon persistence instead, and the next probe can promote it back. + if (target === this.current && !mapped) { + this.target = this.fallback + this.retryAfterMs = Date.now() + DEGRADED_DAEMON_RECOVERY_RETRY_MS + console.warn( + '[daemon] Fresh terminals routed back to the local provider: the daemon failed a spawn after recovering' + ) + } + throw error + } if (!result.exitedBeforeSpawnReply) { this.sessionProviders.set(result.id, target) } diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 7221d783bb3..d9e4cbf679c 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -7444,15 +7444,12 @@ export function registerPtyHandlers( ipcMain.removeAllListeners('pty:signal') ipcMain.on('pty:signal', (_event, args: { id: string; signal: string }) => { - try { - // Why try/catch as well as .catch: routing refuses a session whose host is unreachable, - // and it may do so synchronously. Nothing sits above an ipcMain.on listener to catch it. - tryGetProviderForPty(args.id) - ?.sendSignal(args.id, args.signal) - .catch(() => {}) - } catch { - /* unreachable host — the signal is undeliverable and there is no caller to tell */ - } + // Routing refuses a session whose host is unreachable, but sendSignal is async everywhere, + // so that refusal arrives as a rejection rather than a throw — and optional chaining + // short-circuits the whole chain when there is no provider at all. + tryGetProviderForPty(args.id) + ?.sendSignal(args.id, args.signal) + .catch(() => {}) }) ipcMain.removeAllListeners('pty:clearBuffer') diff --git a/src/renderer/src/components/settings/DaemonDegradedNotice.test.tsx b/src/renderer/src/components/settings/DaemonDegradedNotice.test.tsx index 0fc557c0b7e..950d5c74a31 100644 --- a/src/renderer/src/components/settings/DaemonDegradedNotice.test.tsx +++ b/src/renderer/src/components/settings/DaemonDegradedNotice.test.tsx @@ -29,10 +29,24 @@ describe('DaemonDegradedNotice', () => { expect(html).toMatch(/close when you quit/) }) - it('says the remedy also ends whatever the host is still holding', () => { - // Restarting is not free: it kills the sessions this whole change exists to protect. A - // remedy offered without its cost is how a user loses agents by clicking the helpful button. - expect(render()).toMatch(/ends any session the host is still holding/) + it('does not claim the held daemon’s terminals still work', () => { + // They do not. Discovery runs over the same IPC the daemon is failing to answer, so its + // sessions are never routed and attach refuses the fallback rather than answering on the + // daemon's behalf (degraded-daemon-session-routing.ts:23). The processes are alive — which + // is the whole point of holding — but unreachable until it responds. + const html = render() + expect(html).toMatch(/can’t reach those terminals until the host responds/) + expect(html).not.toMatch(/already open keep working/) + }) + + it('says the restart ends the local terminals too, not just the held ones', () => { + // Restarting is not free twice over: runRestartDaemon kills the daemon's sessions AND calls + // shutdownFallbackSessions() first (daemon-init.ts), so the terminals the notice just told + // the user are running "outside the host" die as well. Naming only half the cost is how a + // user loses work by clicking the button the banner recommended. + expect(render()).toMatch( + /ends every terminal — both the ones it is still holding and the ones running outside it/ + ) }) it('offers the restart action, disabled while another daemon action runs', () => { diff --git a/src/renderer/src/components/settings/DaemonDegradedNotice.tsx b/src/renderer/src/components/settings/DaemonDegradedNotice.tsx index e33bfbaec27..d822632aa8a 100644 --- a/src/renderer/src/components/settings/DaemonDegradedNotice.tsx +++ b/src/renderer/src/components/settings/DaemonDegradedNotice.tsx @@ -6,7 +6,9 @@ import { translate } from '@/i18n/i18n' * Degraded mode used to be rare and transient, so a console warning was enough. It is now the * settled outcome for a daemon the launcher could not classify — it holds one rather than * killing terminals it might still be hosting — which makes it permanent until the user acts. - * A permanent state nothing renders is one the user cannot act on. + * + * This surfaces it beside the Restart action that resolves it. It does not surface it anywhere + * a user who has not opened Settings would see; a status-bar indicator is the obvious next step. */ export function DaemonDegradedNotice(props: { degraded: boolean @@ -27,14 +29,14 @@ export function DaemonDegradedNotice(props: {

{translate( - 'components.settings.DaemonDegradedNotice.title', + 'auto.components.settings.DaemonDegradedNotice.title', 'New terminals aren’t being saved' )}

-

+

{translate( - 'components.settings.DaemonDegradedNotice.body', - 'The terminal host stopped responding, so Orca kept it rather than ending anything it may still be running. Terminals already open keep working. New ones run outside it and will close when you quit Orca. Restarting the host fixes this — it also ends any session the host is still holding.' + 'auto.components.settings.DaemonDegradedNotice.body', + 'The terminal host stopped responding. Orca kept it rather than ending anything it might still be hosting, but it can’t reach those terminals until the host responds again — the panes reconnect on their own if it recovers. New terminals open outside the host and close when you quit Orca. Restarting the host clears this, and ends every terminal — both the ones it is still holding and the ones running outside it.' )}

@@ -46,7 +48,7 @@ export function DaemonDegradedNotice(props: { disabled={props.isBusy} onClick={props.onRestartDaemon} > - {translate('components.settings.DaemonDegradedNotice.action', 'Restart host')} + {translate('auto.components.settings.DaemonDegradedNotice.action', 'Restart host')} ) diff --git a/src/renderer/src/components/settings/ManageSessionsSection.tsx b/src/renderer/src/components/settings/ManageSessionsSection.tsx index d51c5534e1f..40b837344a9 100644 --- a/src/renderer/src/components/settings/ManageSessionsSection.tsx +++ b/src/renderer/src/components/settings/ManageSessionsSection.tsx @@ -112,6 +112,19 @@ export function ManageSessionsSection(): React.JSX.Element { void refresh() }, [refresh]) + // Why refetch on focus: the degraded flag is computed in the main process and never pushed. + // DegradedDaemonFreshSpawnRouter.recover() clears it the moment the daemon answers a health + // check, so a banner rendered at mount can outlive the condition — and it arms a Restart that + // ends every live session. Matches TerminalTccAttributionNotice, which refetches for the same + // reason: a daemon restart or drain changes the verdict without a pane remount. + useEffect(() => { + const onFocus = (): void => { + void refresh() + } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, [refresh]) + const sessionCount = sessions.length const daemonActions = useDaemonActions({ diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a1361ab527b..5fd2b91d8e4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10652,6 +10652,11 @@ "disconnectFailed": "Could not remove the saved Bitbucket credential." } } + }, + "DaemonDegradedNotice": { + "title": "New terminals aren’t being saved", + "body": "The terminal host stopped responding. Orca kept it rather than ending anything it might still be hosting, but it can’t reach those terminals until the host responds again — the panes reconnect on their own if it recovers. New terminals open outside the host and close when you quit Orca. Restarting the host clears this, and ends every terminal — both the ones it is still holding and the ones running outside it.", + "action": "Restart host" } }, "right": { @@ -15293,6 +15298,13 @@ "sent": "Session context sent to {{agent}} in a new session.", "deliveryFailed": "The new {{agent}} session started, but its context could not be sent.", "launchFailed": "Could not start a new {{agent}} session." + }, + "settings": { + "DaemonDegradedNotice": { + "title": "New terminals aren’t being saved", + "body": "The terminal host stopped responding, so Orca kept it rather than ending anything it may still be running. Terminals it was hosting are still running, but Orca can’t reach them until it responds again. New terminals open outside it and will close when you quit Orca. Restarting the host clears this — and ends whatever it was still hosting, so try reopening those terminals first.", + "action": "Restart host" + } } }, "dashboardPopout": {