Files
orca/src/shared/plugins/workers/plugin-host-protocol.ts
T
0173de9b52 fix(plugins): don't count time asleep as worker idle time (#28299)
* fix(plugins): don't count time asleep as worker idle time

The idle reaper measured idleness with the wall clock, so after a sleep longer than the reap window its first sweep reaped every worker before their own timers could fire. A sweep gap well past the 60s interval now starts a fresh idle window.

* fix(plugins): count a sleep before the first idle sweep as asleep, not idle

Start the reaper's sleep-detection clock when the first worker starts, so the
first sweep after waking can see the gap even if no sweep ran before the lid
closed.

* fix(plugins): keep worker manager under max-lines and restore real timers after sleep tests

Move the idle-candidate filter into PluginWorkerIdleClock so the rebased manager
stays within the 300-line lint cap, and reset fake timers after the idle-reap
tests so the sleep-before-first-sweep case does not leak them.

---------

Co-authored-by: petersindex <287557875+petersindex@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
2026-11-07 03:19:46 -08:00

107 lines
4.2 KiB
TypeScript

import { z } from 'zod'
import {
PLUGIN_COMMAND_LIMIT,
PLUGIN_EVENT_NAMES,
pluginCommandIdSchema
} from '../manifest/plugin-manifest'
import { PLUGIN_CAPABILITY_KINDS } from '../capabilities/plugin-capabilities'
import { pluginEvent, type PluginEvent } from '../frames/plugin-protocol'
import { pluginWorkerPanelPostSchema } from './plugin-panel-bridge-protocol'
/**
* The worker fork channel, on the shared envelope (plugin-protocol.ts). Validate both sides:
* third-party worker code cannot supply trusted structure. Host calls are the worker's `req`s;
* the methods and events below are the host's lifecycle traffic.
*/
export const WORKER_EVENTS = {
/** host → worker, {@link PluginWorkerInit}. */
init: 'init',
/** worker → host, `{ commands }` registered on activate. */
ready: 'ready',
log: 'log',
fatal: 'fatal',
shutdown: 'shutdown',
/** worker → host, {@link PluginWorkerPanelPost}: `orca.panels.post`. */
panelPost: 'panel.post',
/** host → worker, {@link PluginDataChanged}: a panel or the person changed storage or settings. */
dataChanged: 'data.changed'
} as const
/** host → worker requests; the worker answers each with a `res`. */
export const WORKER_METHODS = {
invokeCommand: 'command.invoke',
deliverEvent: 'event.deliver',
/** A panel's `plugin.request`, answered by the handler from `orca.panels.handle`. */
panelRequest: 'panel.request',
/** One hook, answered with a decision (plugin-worker-hooks.ts). */
runHook: 'hook.run'
} as const
export const pluginWorkerInitSchema = z.object({
/** Qualified `<publisher>.<id>` key. */
pluginId: z.string().min(1),
pluginRoot: z.string().min(1),
mainEntry: z.string().min(1),
/** Worker-side early refusals only; host rechecks every call regardless of declared grants. */
grantedCapabilities: z.array(z.enum(PLUGIN_CAPABILITY_KINDS))
})
export const pluginWorkerInvokeParamsSchema = z.object({
commandId: z.string().min(1),
args: z.unknown().optional()
})
export const pluginWorkerDeliverParamsSchema = z.object({
event: z.enum(PLUGIN_EVENT_NAMES),
payload: z.unknown()
})
export const pluginWorkerReadySchema = z.object({
/** Command ids the worker registered handlers for (⊆ manifest commands). */
commands: z.array(pluginCommandIdSchema).max(PLUGIN_COMMAND_LIMIT)
})
export const pluginWorkerLogSchema = z.object({
level: z.enum(['info', 'warn', 'error']),
message: z.string().max(8192)
})
export const pluginWorkerFatalSchema = z.object({ error: z.string().max(8192) })
export type PluginWorkerInit = z.infer<typeof pluginWorkerInitSchema>
const pluginWorkerChildEventSchema = z.discriminatedUnion('event', [
z.object({ event: z.literal(WORKER_EVENTS.ready), payload: pluginWorkerReadySchema }),
z.object({ event: z.literal(WORKER_EVENTS.log), payload: pluginWorkerLogSchema }),
z.object({ event: z.literal(WORKER_EVENTS.fatal), payload: pluginWorkerFatalSchema }),
z.object({ event: z.literal(WORKER_EVENTS.panelPost), payload: pluginWorkerPanelPostSchema })
])
/** A worker → host lifecycle event, or null for anything else. */
export function readPluginWorkerEvent(
message: PluginEvent
): z.infer<typeof pluginWorkerChildEventSchema> | null {
const parsed = pluginWorkerChildEventSchema.safeParse(message)
return parsed.success ? parsed.data : null
}
export function pluginWorkerLog(level: 'info' | 'warn' | 'error', message: string): PluginEvent {
return pluginEvent(WORKER_EVENTS.log, { level, message: message.slice(0, 8192) })
}
export function pluginWorkerFatal(error: string): PluginEvent {
return pluginEvent(WORKER_EVENTS.fatal, { error: error.slice(0, 8192) })
}
export const PLUGIN_WORKER_READY_TIMEOUT_MS = 10_000
export const PLUGIN_WORKER_INVOKE_TIMEOUT_MS = 30_000
/** Dispose idle workers and refork on their next trigger. */
export const PLUGIN_WORKER_IDLE_REAP_MS = 5 * 60_000
/** How often the main process sweeps for idle workers. */
export const PLUGIN_WORKER_REAP_INTERVAL_MS = 60_000
/** Default cap on concurrently-active workers; excess activations queue. */
export const PLUGIN_WORKER_MAX_ACTIVE_DEFAULT = 5
/** Soft cap on always-running hook workers, which hold no slot of the cap above. */
export const PLUGIN_WORKER_MAX_KEEP_WARM_DEFAULT = 16