mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix(daemon): repair what round eleven found, including a fix that did nothing
The patient ask was not patient. With twelve seconds reserved for the evidence read, `max(CONNECT, probeBudget - REQUEST)` resolved to exactly CONNECT — the tolerant ask got the cheap ask's four seconds, and the grace loop's gate needed 19s of a budget that only ever held 11s, so it never ran at all. Both shipped green because mocked probes consume no wall clock, so the budget never binds in a test. Two arithmetic tests now compute against realistic elapsed time, which is where the defect actually lived. The reservation was backwards anyway. Evidence can only raise 'unknown' to an uncounted 'occupied', and both now hold the daemon, so the read changes a log line and nothing else — while starving the one probe whose counted answer still reaches preserveDaemon() and full daemon mode. It is opportunistic now: if the probes spent the clock, it is skipped and the verdict stays 'unknown', which holds exactly as an evidence-raised 'occupied' would have. Recovery was a one-way flip on a two-way condition. Once a health check promoted fresh spawns back to the daemon, nothing ever demoted them, so a daemon that wedged again cost a hello timeout plus a full launcher re-classification for every new terminal, for the rest of the session. A failed spawn now routes back and re-arms the cooldown. The class had no tests at all; it has six. The notice was wrong twice. It claimed terminals already open keep working — in held mode discovery runs over the same IPC the daemon is failing, so its sessions are never routed and attach refuses rather than answering on its behalf. They are running, but unreachable. And it named half the cost of Restart: runRestartDaemon shuts down the local fallback sessions too, so the terminals it had just called safe die as well. Also fixed: the amber-on-amber body text failed AA at 3.94:1, the keys were in a namespace no sibling uses, and the scale did not match the notice it renders beside. The banner armed a destructive button with state that never refreshed. It now refetches on focus, like the sibling notice that solved this first. Also: the launch mode type in the test harness omitted 'held', so no test could describe the launch the hold produces; held mode's routing through the degraded provider was unpinned, and deleting it left every test green; and pty:signal kept a try/catch for a synchronous throw that async routing cannot produce.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<typeof vi.fn>[] = []
|
||||
const lifecycleLeaseErrors: Error[] = []
|
||||
@@ -343,7 +346,7 @@ vi.mock('./daemon-spawner', () => ({
|
||||
readonly getHandle: ReturnType<typeof vi.fn>
|
||||
private socketCounter: number
|
||||
private handle: {
|
||||
mode?: 'degraded-new-pty-fallback'
|
||||
mode?: DaemonLaunchMode
|
||||
releaseAdoptionLease?: () => void
|
||||
shutdown: () => Promise<void>
|
||||
} | 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<typeof DegradedDaemonPtyProvider>).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<void>
|
||||
}>
|
||||
checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy')
|
||||
|
||||
@@ -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<DaemonOccupancy> =>
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<boolean>) | null
|
||||
currentSpawn?: IPtyProvider['spawn']
|
||||
}) {
|
||||
const current = provider('current', opts.currentSpawn)
|
||||
const fallback = provider('fallback')
|
||||
const sessionProviders = new Map<string, IPtyProvider>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -68,7 +68,24 @@ export class DegradedDaemonFreshSpawnRouter {
|
||||
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
|
||||
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)
|
||||
}
|
||||
|
||||
+6
-9
@@ -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')
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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: {
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{translate(
|
||||
'components.settings.DaemonDegradedNotice.title',
|
||||
'auto.components.settings.DaemonDegradedNotice.title',
|
||||
'New terminals aren’t being saved'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm opacity-90">
|
||||
<p className="text-xs leading-snug">
|
||||
{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.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -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')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user