feat(wsl-host): start a WSL host's distro when one of its automations comes due (E9 B) (#33839)

* feat(wsl-host): start a WSL host's distro when one of its automations comes due

E9 connect policy, part 2. A WSL host's automations run inside its distro, so a
distro that is off (a reboot, an idle release) missed them. While a host is connected
the desktop now reads its enabled automations' next run (on connect and every 15 min)
and keeps the soonest one per host in userData. When that time comes with the host off,
it boots the distro once, and orcad's startup catch-up fires the run within its missed-
run grace. A run due while the computer was off fires once Orca starts. Nothing wakes
past the grace window, and a connected host is left to its own scheduler.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(wsl-host): a due automation boots a parked host once, never with WSL hosts off

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(wsl-host): keep the following run, and forget changed automations (E9 review)

Review of #33839:
- A run handled while connected dropped the host's next due time. The wake now keeps
  each host's enabled schedules and computes the following run locally whatever
  happens to this one (wake failed, past grace, handled by the host), and re-reads the
  real one from a connected host.
- A deleted, disabled or rescheduled automation kept waking the distro at its old
  time. A successful automation.create/update/delete from this desktop now re-reads the
  host's automations, as do the 15-minute re-read and every connect. Nothing boots a
  distro to check: on wake, a distro already running is just reconnected.
- wakeForAutomation/isConnected live here now (moved out of #33838); isConnected reads
  the host's reachability, never booting.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(wsl-host): say isConnected answers for this process; keep the service in bounds

isConnected reports whether the host answers through this process; false is not
"stopped" (a fresh process has no bridge yet, and the wake just connects). The
service's quiet record read moves beside the other record helpers so the file stays
within its line limit after the E9 additions.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(wsl-host): give the connector contract its own file; one wake path for focus and automations

After rebasing onto #34063, #34093 and #33110 the WSL host service was over its line
limit. The connector contract (what the service drives) moves to
wsl-host-connector-contract.ts, re-exported from the service, and the focus entry and
the automation wake share one private wake step: lift the park, then reconnect unless
the host answers, read with #33110's focusReachability so neither pays a WSL round trip
when this process has no bridge yet. An automation wake no longer reconnects a host
that already answers.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(wsl-host): an automation wake with no bridge skips the reachability probe

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-26 16:07:16 -07:00
committed by GitHub
co-authored by Claude
parent f06f737802
commit 0fef4a8ce6
10 changed files with 705 additions and 55 deletions
@@ -0,0 +1,69 @@
/** Production wiring for the E9 automation wake: host automations over runtime RPC, a userData file. */
import type { Automation } from '../../shared/automations-types'
import {
readWslHostAutomationWakes,
WslHostAutomationWake,
writeWslHostAutomationWakes
} from '../wsl/wsl-host-automation-wake'
import type { WslHostService } from '../wsl/wsl-host-service'
import { callRuntimeEnvironment } from './runtime-environments/runtime-environment-transport-routing'
import { onWslOrcadHostAutomationsChanged } from '../wsl/wsl-orcad-host-environments'
// A connected host's schedule can change from any client; re-read it this often.
const RELEARN_MS = 15 * 60 * 1000
export function createWslHostAutomationWake(
userDataPath: () => string,
service: WslHostService
): WslHostAutomationWake {
const wake = new WslHostAutomationWake({
load: () => readWslHostAutomationWakes(userDataPath()),
save: (due) => writeWslHostAutomationWakes(userDataPath(), due),
listAutomations: async (environmentId) => {
const response = await callRuntimeEnvironment(
userDataPath(),
environmentId,
'automation.list',
undefined
)
if (!response.ok) {
throw new Error(response.error.message)
}
const result = response.result
return result && typeof result === 'object' && 'automations' in result
? automationsFrom(result.automations)
: []
},
isConnected: (environmentId) => service.isConnected(environmentId),
wake: (environmentId) => service.wakeForAutomation(environmentId)
})
const relearn = setInterval(() => {
for (const status of service.getStatuses()) {
if (status.state === 'ready') {
void wake.learn(status.environmentId)
}
}
}, RELEARN_MS)
relearn.unref?.()
// A deleted, disabled or rescheduled automation must not keep its old wake time.
onWslOrcadHostAutomationsChanged((environmentId) => void wake.learn(environmentId))
wake.start()
return wake
}
/** Only rows carrying what the wake reads; anything else from an odd host is ignored. */
function automationsFrom(value: unknown): Automation[] {
if (!Array.isArray(value)) {
return []
}
return value.filter(
(row): row is Automation =>
Boolean(row) &&
typeof row === 'object' &&
typeof row.enabled === 'boolean' &&
typeof row.rrule === 'string' &&
typeof row.dtstart === 'number' &&
typeof row.nextRunAt === 'number' &&
typeof row.missedRunGraceMinutes === 'number'
)
}
+9 -2
View File
@@ -14,6 +14,8 @@ import {
import { hasOrcadTemplate } from '../orcad/orcad-artifact-materializer'
import { WslHostService } from '../wsl/wsl-host-service'
import { WslHostVisibleWorkByWindow } from './wsl-host-visible-work-windows'
import type { WslHostAutomationWake } from '../wsl/wsl-host-automation-wake'
import { createWslHostAutomationWake } from './wsl-host-automation-wake-wiring'
import { wslHostsGateOpen } from '../wsl/wsl-host-gate'
import { getWslHostService, setWslHostService } from '../wsl/wsl-host-service-instance'
import { retireRemovedRuntimeEnvironment } from './runtime-environments/runtime-environment-removal-cleanup'
@@ -111,6 +113,7 @@ function createService(args: WslHostHandlerArgs): WslHostService | null {
if (process.platform !== 'win32' && !fake) {
return null
}
let automationWake: WslHostAutomationWake | null = null
const created = new WslHostService({
userDataPath: args.getUserDataPath,
isEnabled: () => args.store.getSettings().experimentalWslHosts === true,
@@ -125,14 +128,17 @@ function createService(args: WslHostHandlerArgs): WslHostService | null {
if (distro) {
pushHostAccounts(args.getUserDataPath, environmentId, distro)
}
void automationWake?.learn(environmentId)
},
removed: (environmentId) =>
retireRemovedRuntimeEnvironment(
removed: (environmentId) => {
automationWake?.forget(environmentId)
return retireRemovedRuntimeEnvironment(
environmentId,
args.invalidateTransport,
args.store,
args.getUserDataPath()
)
}
})
const onChange = created.handleConnectionChange.bind(created)
const isKnownDistro = (distro: string): boolean => created.isKnownDistro(distro)
@@ -148,6 +154,7 @@ function createService(args: WslHostHandlerArgs): WslHostService | null {
runtime
})
)
automationWake = createWslHostAutomationWake(args.getUserDataPath, created)
return created
}
@@ -0,0 +1,203 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Automation } from '../../shared/automations-types'
import { nextAutomationOccurrenceAfter } from '../../shared/automation-schedule-occurrences'
import {
earliestWslHostAutomationDue,
nextWslHostAutomationDue,
readWslHostAutomationWakes,
WslHostAutomationWake,
writeWslHostAutomationWakes,
type WslHostAutomationDue
} from './wsl-host-automation-wake'
const HOUR = 60 * 60 * 1000
const HOURLY = '0 * * * *'
function automation(overrides: Partial<Automation>): Automation {
return {
id: 'a',
name: 'nightly',
prompt: '',
precheck: null,
agentId: 'claude',
projectId: 'p',
executionTargetType: 'local',
executionTargetId: 'p',
schedulerOwner: 'remote_host_service',
workspaceMode: 'new_per_run',
workspaceId: null,
baseBranch: null,
reuseSession: false,
timezone: 'UTC',
rrule: HOURLY,
dtstart: 0,
enabled: true,
nextRunAt: 10 * HOUR,
missedRunPolicy: 'run_once_within_grace',
missedRunGraceMinutes: 720,
createdAt: 0,
updatedAt: 0,
...overrides
}
}
function harness(options: { due?: Record<string, WslHostAutomationDue>; connected?: boolean }) {
let now = 0
let timer: { at: number; task: () => void } | null = null
const saved: Record<string, WslHostAutomationDue>[] = []
const wake = vi.fn(async (_environmentId: string) => {})
const automations = vi.fn(async (): Promise<Automation[]> => [automation({})])
const subject = new WslHostAutomationWake({
load: () => options.due ?? {},
save: (due) => saved.push(due),
listAutomations: automations,
isConnected: async () => options.connected ?? false,
wake,
now: () => now,
setTimer: (delayMs, task) => {
timer = { at: now + delayMs, task }
return () => {
timer = null
}
}
})
return {
subject,
wake,
automations,
saved,
timerAt: () => timer?.at ?? null,
/** Moves the clock to the armed timer and runs it. */
async runTimer(at: number) {
now = at
const current = timer
const savesBefore = saved.length
timer = null
current?.task()
await vi.waitFor(() => expect(saved.length).toBeGreaterThan(savesBefore))
}
}
}
function due(nextRunAt: number, graceHours = 12): WslHostAutomationDue {
return {
nextRunAt,
wakeUntil: nextRunAt + graceHours * HOUR,
schedules: [{ rrule: HOURLY, dtstart: 0, missedRunGraceMinutes: graceHours * 60 }]
}
}
describe('earliestWslHostAutomationDue', () => {
it('takes the soonest enabled run, its grace window, and every enabled schedule', () => {
expect(
earliestWslHostAutomationDue([
automation({ nextRunAt: 5 * HOUR, enabled: false }),
automation({ nextRunAt: 8 * HOUR, missedRunGraceMinutes: 60 }),
automation({ nextRunAt: 9 * HOUR })
])
).toEqual({
nextRunAt: 8 * HOUR,
wakeUntil: 9 * HOUR + 2 * 60_000,
schedules: [
{ rrule: HOURLY, dtstart: 0, missedRunGraceMinutes: 60 },
{ rrule: HOURLY, dtstart: 0, missedRunGraceMinutes: 720 }
]
})
expect(earliestWslHostAutomationDue([automation({ enabled: false })])).toBeNull()
})
it('computes the following run from the schedules alone', () => {
const now = 10 * HOUR + 5 * 60_000
expect(nextWslHostAutomationDue(due(0).schedules, now)?.nextRunAt).toBe(
nextAutomationOccurrenceAfter(HOURLY, 0, now)
)
})
})
describe('WslHostAutomationWake', () => {
it('boots the host once when a learned run comes due with the host off', async () => {
const h = harness({})
await h.subject.learn('wsl:ubuntu')
expect(h.timerAt()).toBe(HOUR) // capped re-arm, not 10 h
await h.runTimer(HOUR)
expect(h.wake).not.toHaveBeenCalled()
await h.runTimer(10 * HOUR)
expect(h.wake).toHaveBeenCalledExactlyOnceWith('wsl:ubuntu')
})
it('keeps the following run when a due one was handled while the host was connected', async () => {
const h = harness({ due: { 'wsl:ubuntu': due(-HOUR) }, connected: true })
h.automations.mockResolvedValueOnce([automation({ nextRunAt: 2 * HOUR })])
h.subject.start()
await h.runTimer(0)
expect(h.wake).not.toHaveBeenCalled()
// Re-read from the connected host, not dropped.
expect(h.saved.at(-1)?.['wsl:ubuntu']?.nextRunAt).toBe(2 * HOUR)
})
it('keeps the following run when a wake fails or the run is past its grace', async () => {
const failed = harness({ due: { 'wsl:ubuntu': due(-HOUR) } })
failed.wake.mockRejectedValueOnce(new Error('boot failed'))
failed.subject.start()
await failed.runTimer(0)
const late = harness({ due: { 'wsl:ubuntu': due(-3 * HOUR, 1) } })
late.subject.start()
await late.runTimer(0)
expect(late.wake).not.toHaveBeenCalled()
for (const { saved } of [failed, late]) {
expect(saved.at(-1)?.['wsl:ubuntu']?.nextRunAt).toBe(
nextAutomationOccurrenceAfter(HOURLY, 0, 0)
)
}
})
it('fires a run that came due while the computer was off as soon as Orca starts', async () => {
const h = harness({ due: { 'wsl:ubuntu': due(-HOUR) } })
h.subject.start()
expect(h.timerAt()).toBe(0)
await h.runTimer(0)
expect(h.wake).toHaveBeenCalledExactlyOnceWith('wsl:ubuntu')
})
it('a deleted or disabled automation no longer wakes the host once this desktop re-learns', async () => {
const h = harness({ due: { 'wsl:ubuntu': due(5 * HOUR) } })
h.automations.mockResolvedValueOnce([automation({ enabled: false })])
await h.subject.learn('wsl:ubuntu')
expect(h.saved.at(-1)).toEqual({})
expect(h.timerAt()).toBeNull()
})
it('keeps what it knew when the host cannot be read, and forgets a removed host', async () => {
const h = harness({ due: { 'wsl:ubuntu': due(5 * HOUR) } })
h.automations.mockRejectedValueOnce(new Error('offline'))
await h.subject.learn('wsl:ubuntu')
expect(h.saved).toEqual([])
h.subject.forget('wsl:ubuntu')
expect(h.saved.at(-1)).toEqual({})
expect(h.timerAt()).toBeNull()
})
})
describe('persisted wake times', () => {
let dir: string | null = null
afterEach(() => {
if (dir) {
rmSync(dir, { recursive: true, force: true })
}
})
it('round-trips and drops malformed entries', () => {
dir = mkdtempSync(join(tmpdir(), 'orca-wsl-wake-'))
writeWslHostAutomationWakes(dir, { a: due(1) })
expect(readWslHostAutomationWakes(dir)).toEqual({ a: due(1) })
writeFileSync(
join(dir, 'wsl-host-automation-wakes.json'),
JSON.stringify({ b: { nextRunAt: 1 } })
)
expect(readWslHostAutomationWakes(dir)).toEqual({})
expect(readWslHostAutomationWakes(join(dir, 'missing'))).toEqual({})
})
})
+256
View File
@@ -0,0 +1,256 @@
/**
* E9: a WSL host's automations run inside its distro, so a stopped distro misses them. While a host
* is connected (and after this desktop changes its automations) the desktop learns its enabled
* schedules and keeps them across restarts; when the soonest run comes due with the host off, this
* boots the distro once and orcad's startup catch-up fires the run. A due run is the user's own
* standing request, so it is the one non-interactive wake. Nothing here boots a distro to check.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { Automation } from '../../shared/automations-types'
import { nextAutomationOccurrenceAfter } from '../../shared/automation-schedule-occurrences'
export type WslHostAutomationSchedule = {
rrule: string
dtstart: number
missedRunGraceMinutes: number
}
export type WslHostAutomationDue = {
nextRunAt: number
/** Past this, orcad would only record the run as missed, so a wake is pointless. */
wakeUntil: number
/** Every enabled schedule, so the next due time survives a run handled while off. */
schedules: WslHostAutomationSchedule[]
}
export type WslHostAutomationWakeDeps = {
load: () => Record<string, WslHostAutomationDue>
save: (due: Record<string, WslHostAutomationDue>) => void
listAutomations: (environmentId: string) => Promise<Automation[]>
/**
* Whether the host's server answers through this process now; never starts a stopped distro.
* False is not "stopped": a fresh process has no bridge yet, and the wake then just connects.
*/
isConnected: (environmentId: string) => Promise<boolean>
/** Boots the host's distro and connects; it may refuse (host removed, gate closed). */
wake: (environmentId: string) => Promise<void>
now?: () => number
setTimer?: (delayMs: number, task: () => void) => () => void
}
// Re-arm at least this often, so a clock change or sleep can't strand a far-off timer.
const MAX_TIMER_MS = 60 * 60 * 1000
// orcad's own tick jitter allowance on top of each automation's grace.
const GRACE_SLACK_MS = 2 * 60 * 1000
/** The soonest enabled run and the schedules behind it, or null when nothing is scheduled. */
export function earliestWslHostAutomationDue(
automations: readonly Automation[]
): WslHostAutomationDue | null {
const enabled = automations.filter(
(automation) => automation.enabled && Number.isFinite(automation.nextRunAt)
)
const soonest = enabled.reduce<Automation | null>(
(best, automation) => (!best || automation.nextRunAt < best.nextRunAt ? automation : best),
null
)
return soonest
? {
nextRunAt: soonest.nextRunAt,
wakeUntil: graceEnd(soonest.nextRunAt, soonest.missedRunGraceMinutes),
schedules: enabled.map(({ rrule, dtstart, missedRunGraceMinutes }) => ({
rrule,
dtstart,
missedRunGraceMinutes
}))
}
: null
}
/** The next due run strictly after `after`, from the learned schedules. */
export function nextWslHostAutomationDue(
schedules: readonly WslHostAutomationSchedule[],
after: number
): WslHostAutomationDue | null {
let due: WslHostAutomationDue | null = null
for (const schedule of schedules) {
let nextRunAt: number
try {
nextRunAt = nextAutomationOccurrenceAfter(schedule.rrule, schedule.dtstart, after)
} catch {
continue
}
if (Number.isFinite(nextRunAt) && (!due || nextRunAt < due.nextRunAt)) {
due = {
nextRunAt,
wakeUntil: graceEnd(nextRunAt, schedule.missedRunGraceMinutes),
schedules: [...schedules]
}
}
}
return due
}
function graceEnd(nextRunAt: number, graceMinutes: number): number {
return nextRunAt + graceMinutes * 60_000 + GRACE_SLACK_MS
}
export class WslHostAutomationWake {
private due: Record<string, WslHostAutomationDue>
private cancelTimer: (() => void) | null = null
private readonly now: () => number
private readonly setTimer: (delayMs: number, task: () => void) => () => void
constructor(private readonly deps: WslHostAutomationWakeDeps) {
this.now = deps.now ?? Date.now
this.setTimer =
deps.setTimer ??
((delayMs, task) => {
const timer = setTimeout(task, delayMs)
timer.unref?.()
return () => clearTimeout(timer)
})
this.due = deps.load()
}
/** Arms the timer from what earlier runs learned; a run due during a reboot fires now. */
start(): void {
this.arm()
}
/** Called while the host is connected; a failed read keeps what was known. */
async learn(environmentId: string): Promise<void> {
let next: WslHostAutomationDue | null
try {
next = earliestWslHostAutomationDue(await this.deps.listAutomations(environmentId))
} catch {
return
}
this.set(environmentId, next)
this.persist()
this.arm()
}
forget(environmentId: string): void {
this.set(environmentId, null)
this.persist()
this.arm()
}
dispose(): void {
this.cancelTimer?.()
this.cancelTimer = null
}
private set(environmentId: string, next: WslHostAutomationDue | null): void {
const { [environmentId]: _previous, ...rest } = this.due
this.due = next ? { ...rest, [environmentId]: next } : rest
}
private persist(): void {
try {
this.deps.save(this.due)
} catch (error) {
console.warn('[wsl-hosts] Could not save automation wake times:', error)
}
}
private arm(): void {
this.cancelTimer?.()
const times = Object.values(this.due).map((entry) => entry.nextRunAt)
if (times.length === 0) {
this.cancelTimer = null
return
}
const delayMs = Math.min(Math.max(0, Math.min(...times) - this.now()), MAX_TIMER_MS)
this.cancelTimer = this.setTimer(delayMs, () => void this.fire())
}
private async fire(): Promise<void> {
const now = this.now()
for (const [environmentId, entry] of Object.entries(this.due)) {
if (entry.nextRunAt > now) {
continue
}
// Keep the following run whatever happens to this one; a connect re-learns the truth.
this.set(environmentId, nextWslHostAutomationDue(entry.schedules, now))
if (await this.deps.isConnected(environmentId)) {
// Its own scheduler ran it; read the real next run instead of the local estimate.
await this.learn(environmentId)
continue
}
if (now > entry.wakeUntil) {
continue
}
await this.deps.wake(environmentId).catch((error: unknown) => {
console.warn(`[wsl-hosts] Could not start ${environmentId} for a scheduled run:`, error)
})
}
this.persist()
this.arm()
}
}
const FILE_NAME = 'wsl-host-automation-wakes.json'
export function readWslHostAutomationWakes(
userDataPath: string
): Record<string, WslHostAutomationDue> {
try {
const parsed: unknown = JSON.parse(readFileSync(join(userDataPath, FILE_NAME), 'utf8'))
const due: Record<string, WslHostAutomationDue> = {}
if (parsed && typeof parsed === 'object') {
for (const [environmentId, value] of Object.entries(parsed)) {
const entry = parseDue(value)
if (entry) {
due[environmentId] = entry
}
}
}
return due
} catch {
return {}
}
}
function parseDue(value: unknown): WslHostAutomationDue | null {
if (
!value ||
typeof value !== 'object' ||
!('nextRunAt' in value) ||
!('wakeUntil' in value) ||
!('schedules' in value) ||
typeof value.nextRunAt !== 'number' ||
typeof value.wakeUntil !== 'number' ||
!Array.isArray(value.schedules)
) {
return null
}
const schedules = value.schedules.flatMap((schedule: unknown): WslHostAutomationSchedule[] =>
schedule &&
typeof schedule === 'object' &&
'rrule' in schedule &&
'dtstart' in schedule &&
'missedRunGraceMinutes' in schedule &&
typeof schedule.rrule === 'string' &&
typeof schedule.dtstart === 'number' &&
typeof schedule.missedRunGraceMinutes === 'number'
? [
{
rrule: schedule.rrule,
dtstart: schedule.dtstart,
missedRunGraceMinutes: schedule.missedRunGraceMinutes
}
]
: []
)
return { nextRunAt: value.nextRunAt, wakeUntil: value.wakeUntil, schedules }
}
export function writeWslHostAutomationWakes(
userDataPath: string,
due: Record<string, WslHostAutomationDue>
): void {
writeFileSync(join(userDataPath, FILE_NAME), JSON.stringify(due))
}
+14 -1
View File
@@ -1,5 +1,8 @@
import { refreshWslHostPairing } from '../../shared/runtime-environment-wsl-host-store'
import type { WslHostDeploymentRecord } from '../../shared/wsl-host-deployment-store'
import {
readWslHostDeployments,
type WslHostDeploymentRecord
} from '../../shared/wsl-host-deployment-store'
import type { WslHostStatus } from '../../shared/wsl-host-types'
import type { WslOrcadConnection } from './wsl-orcad-connection'
import type { WslOrcadConnectionStatus } from './wsl-orcad-connection-status'
@@ -41,3 +44,13 @@ export function toWslHostStatus(
: null
}
}
/** The registered WSL hosts; an unreadable store reads as none rather than failing the caller. */
export function readWslHostRecordsQuietly(userDataPath: string): WslHostDeploymentRecord[] {
try {
return readWslHostDeployments(userDataPath).deployments
} catch (error) {
console.warn('[wsl-hosts] Could not read WSL host records:', error)
return []
}
}
@@ -0,0 +1,32 @@
/** What the WSL host service drives: the C3 holder, C5 connections and C6 liveness, or an e2e fake. */
import type { WslHostServerStop } from '../../shared/wsl-host-types'
import type { WslHostDistroCandidate } from './wsl-host-distro-list'
import type { WslOrcadConnection } from './wsl-orcad-connection'
import type { WslOrcadConnectIntent } from './wsl-orcad-connection-status'
/** `stopped` and `unreachable` rest on a confirmed running list; `unavailable` could not read it. */
export type WslHostReachability = 'available' | 'unreachable' | 'stopped' | 'unavailable'
/** The WSL side the service drives: C3 holder, C5 connections, C6 liveness (or an e2e fake). */
export type WslHostConnector = {
listDistros(): Promise<WslHostDistroCandidate[]>
/** Runs in the distro, so it boots a stopped one: only after the user chose to. */
readOrcadPublicKey(distro: string): Promise<string | null>
/** User-initiated; may boot the distro. An intent retries or forces a skipped update. */
connect(distro: string, options?: WslOrcadConnectIntent): Promise<WslOrcadConnection>
/** Explicit retry must clear pairing-repair failures and spent caps. */
forgetPairingFailures?(environmentId: string): void
get(distro: string): WslOrcadConnection | null
/** Checks the paired server without starting a stopped or unconfirmed distro. */
isReachable(distro: string, environmentId: string): Promise<boolean>
/** The same check, telling a confirmed-stopped distro from an unconfirmed one. */
reachability(distro: string, environmentId: string): Promise<WslHostReachability>
/** Liveness is deciding whether the distro stopped; its running list may be stale. */
isSettling(distro: string): boolean
disconnect(distro: string): Promise<void>
/** Ends the host's terminals, orcad and its terminal daemon; never boots a stopped distro. */
stopServer(distro: string, environmentId: string): Promise<WslHostServerStop>
releaseHolder(distro: string): Promise<void>
adoptHolders(): Promise<void>
dispose(): Promise<void>
}
+32
View File
@@ -589,6 +589,38 @@ describe('WslHostService', () => {
expect(connector.connect).toHaveBeenCalledWith('Ubuntu', { noBoot: true })
})
it('a due automation boots a parked host once, and never with the experiment off', async () => {
const connector = makeConnector([UBUNTU])
const service = makeService(connector)
await service.add({ distro: 'Ubuntu' })
await connector.disconnect('Ubuntu')
service.handleConnectionChange('Ubuntu', null, {
state: 'distro-stopped',
terminals: 'exited',
recovering: false
})
connector.connect.mockClear()
enabled = false
await service.wakeForAutomation('wsl:ubuntu')
expect(connector.connect).not.toHaveBeenCalled()
enabled = true
await service.wakeForAutomation('wsl:ubuntu')
expect(connector.connect).toHaveBeenCalledExactlyOnceWith('Ubuntu')
await expect(service.isConnected('wsl:ubuntu')).resolves.toBe(true)
})
it('an automation wake with no bridge yet connects without a WSL reachability probe', async () => {
const connector = makeConnector([UBUNTU])
const service = makeService(connector)
await service.add({ distro: 'Ubuntu' })
await connector.disconnect('Ubuntu')
connector.connect.mockClear()
connector.reachability.mockClear()
await service.wakeForAutomation('wsl:ubuntu')
expect(connector.reachability).not.toHaveBeenCalled()
expect(connector.connect).toHaveBeenCalledExactlyOnceWith('Ubuntu')
})
it('lifts a park on an explicit Connect', async () => {
const connector = makeConnector([UBUNTU])
const service = makeService(connector)
+44 -51
View File
@@ -6,10 +6,7 @@
import { errorMessage } from '../../shared/error-message'
import { focusReachability } from './wsl-host-focus-reachability'
import { removeWslHostEnvironment } from '../../shared/runtime-environment-wsl-host-store'
import {
readWslHostDeployments,
type WslHostDeploymentRecord
} from '../../shared/wsl-host-deployment-store'
import type { WslHostDeploymentRecord } from '../../shared/wsl-host-deployment-store'
import type {
WslHostAddResult,
WslHostConnectIntent,
@@ -17,9 +14,13 @@ import type {
WslHostServerStop,
WslHostStatus
} from '../../shared/wsl-host-types'
import type { WslHostDistroCandidate } from './wsl-host-distro-list'
import type { WslHostConnector } from './wsl-host-connector-contract'
import { wslDistroHolderKey } from './wsl-distro-holder-record'
import { persistWslHostConnection, toWslHostStatus } from './wsl-host-connection-record'
import {
persistWslHostConnection,
readWslHostRecordsQuietly,
toWslHostStatus
} from './wsl-host-connection-record'
import {
sameWslDistro,
wslHostNotConnectedMessage,
@@ -29,39 +30,14 @@ import {
} from './wsl-host-add-admission'
import type { WslOrcadConnection } from './wsl-orcad-connection'
import { registerWslHost } from './wsl-host-registration'
import type { WslOrcadConnectIntent, WslOrcadConnectionStatus } from './wsl-orcad-connection-status'
import type { WslOrcadConnectionStatus } from './wsl-orcad-connection-status'
import {
forgetWslHostToWake,
forgetWslHostsToWake,
rememberWslHostsToWake
} from './wsl-host-early-wake'
/** `stopped` and `unreachable` rest on a confirmed running list; `unavailable` could not read it. */
export type WslHostReachability = 'available' | 'unreachable' | 'stopped' | 'unavailable'
/** The WSL side the service drives: C3 holder, C5 connections, C6 liveness (or an e2e fake). */
export type WslHostConnector = {
listDistros(): Promise<WslHostDistroCandidate[]>
/** Runs in the distro, so it boots a stopped one: only after the user chose to. */
readOrcadPublicKey(distro: string): Promise<string | null>
/** User-initiated; may boot the distro. An intent retries or forces a skipped update. */
connect(distro: string, options?: WslOrcadConnectIntent): Promise<WslOrcadConnection>
/** Explicit retry must clear pairing-repair failures and spent caps. */
forgetPairingFailures?(environmentId: string): void
get(distro: string): WslOrcadConnection | null
/** Checks the paired server without starting a stopped or unconfirmed distro. */
isReachable(distro: string, environmentId: string): Promise<boolean>
/** The same check, telling a confirmed-stopped distro from an unconfirmed one. */
reachability(distro: string, environmentId: string): Promise<WslHostReachability>
/** Liveness is deciding whether the distro stopped; its running list may be stale. */
isSettling(distro: string): boolean
disconnect(distro: string): Promise<void>
/** Ends the host's terminals, orcad and its terminal daemon; never boots a stopped distro. */
stopServer(distro: string, environmentId: string): Promise<WslHostServerStop>
releaseHolder(distro: string): Promise<void>
adoptHolders(): Promise<void>
dispose(): Promise<void>
}
export type { WslHostConnector, WslHostReachability } from './wsl-host-connector-contract'
export type WslHostServiceDependencies = {
userDataPath: () => string
@@ -286,21 +262,43 @@ export class WslHostService {
*/
focusHost(environmentId: string | null): void {
const record = environmentId ? this.recordForEnvironment(environmentId) : null
const runnable =
record && this.connector && this.canRun() && !this.removing.has(record.environmentId)
const runnable = record !== null && this.mayWake(record)
// The next launch starts booting only this distro, early (none when off WSL or not runnable).
rememberWslHostsToWake(this.deps.userDataPath(), runnable ? [record.distro] : [])
if (!record || !runnable || !this.connector) {
return
}
this.parked.delete(record.environmentId)
void focusReachability(this.connector, record)
.then((reachability) =>
reachability === 'available' ? undefined : this.connector?.connect(record.distro)
)
.catch((error: unknown) => {
rememberWslHostsToWake(this.deps.userDataPath(), record && runnable ? [record.distro] : [])
if (record && runnable) {
this.wake(record).catch((error: unknown) => {
console.warn(`[wsl-hosts] Could not connect to ${record.distro}:`, error)
})
}
}
/** E9: an enabled automation came due with the host off; boots the distro once, lifting a park. */
async wakeForAutomation(environmentId: string): Promise<void> {
const record = this.recordForEnvironment(environmentId)
if (record && this.mayWake(record)) {
await this.wake(record)
}
}
private mayWake(record: WslHostDeploymentRecord): boolean {
return Boolean(this.connector) && this.canRun() && !this.removing.has(record.environmentId)
}
/** A focus entry or a due automation: lifts a park and may boot the distro. */
private async wake(record: WslHostDeploymentRecord): Promise<void> {
this.parked.delete(record.environmentId)
// No bridge in this process yet means nothing answers; skip the WSL round trip (#33110).
if (this.connector && (await focusReachability(this.connector, record)) !== 'available') {
await this.connector.connect(record.distro)
}
}
/** Whether the host's server answers through this process now; never starts a stopped distro. */
async isConnected(environmentId: string): Promise<boolean> {
const record = this.recordForEnvironment(environmentId)
return record && this.connector
? (await this.connector.reachability(record.distro, environmentId)) === 'available'
: false
}
/** Whether anything may still be started for this distro (spawn guard). */
@@ -353,12 +351,7 @@ export class WslHostService {
}
private records(): WslHostDeploymentRecord[] {
try {
return readWslHostDeployments(this.deps.userDataPath()).deployments
} catch (error) {
console.warn('[wsl-hosts] Could not read WSL host records:', error)
return []
}
return readWslHostRecordsQuietly(this.deps.userDataPath())
}
private recordForDistro(distro: string): WslHostDeploymentRecord | null {
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import {
observeWslOrcadHostResponse,
onWslOrcadHostAutomationsChanged,
registerWslOrcadHostEnvironment,
unregisterWslOrcadHostEnvironment,
wslOrcadHostSupports
@@ -64,3 +65,25 @@ describe('wslOrcadHostSupports', () => {
)
})
})
describe('onWslOrcadHostAutomationsChanged (E9)', () => {
afterEach(() => unregisterWslOrcadHostEnvironment('env-1', 'Ubuntu'))
it('tells the wake schedule when this desktop changed a WSL host’s automations', () => {
registerWslOrcadHostEnvironment('env-1', 'Ubuntu')
const listener = vi.fn()
const stop = onWslOrcadHostAutomationsChanged(listener)
const ok = (id: string): RuntimeRpcResponse<unknown> => ({
id,
ok: true,
result: {},
_meta: { runtimeId: 'r' }
})
observeWslOrcadHostResponse('env-1', 'automation.delete', ok('automation.delete'))
observeWslOrcadHostResponse('env-1', 'automation.update', ok('automation.update'))
observeWslOrcadHostResponse('env-1', 'automation.list', ok('automation.list'))
observeWslOrcadHostResponse('ssh-box', 'automation.create', ok('automation.create'))
expect(listener.mock.calls).toEqual([['env-1'], ['env-1']])
stop()
})
})
+23 -1
View File
@@ -47,12 +47,34 @@ function capabilitiesOf(result: unknown): readonly string[] | null {
}
/** Records a WSL orcad's capabilities from any `status.get` answer that passes the desktop. */
const AUTOMATION_MUTATIONS = new Set([
'automation.create',
'automation.update',
'automation.delete'
])
const automationMutationListeners = new Set<(environmentId: string) => void>()
/** E9: this desktop changed a WSL host's automations; the wake schedule re-reads them. */
export function onWslOrcadHostAutomationsChanged(
listener: (environmentId: string) => void
): () => void {
automationMutationListeners.add(listener)
return () => automationMutationListeners.delete(listener)
}
export function observeWslOrcadHostResponse(
environmentId: string,
method: string,
response: RuntimeRpcResponse<unknown>
): void {
if (method !== 'status.get' || !response.ok || !distrosByEnvironment.has(environmentId)) {
if (!response.ok || !distrosByEnvironment.has(environmentId)) {
return
}
if (AUTOMATION_MUTATIONS.has(method)) {
automationMutationListeners.forEach((listener) => listener(environmentId))
return
}
if (method !== 'status.get') {
return
}
const capabilities = capabilitiesOf(response.result)