import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway' import { nextRunAtMs } from './core/schedule.ts' import { reusableSessionId } from './core/session-reuse.ts' import { HostTaskLedger, type OpenedRun, type OpenExecutionReference } from './host-ledger.ts' import { HostExecutionRunner, SessionLaunchError, type SessionCommandDispatcher, type SessionSummary, type TaskBoardWorkspaceRegistry } from './host-runner.ts' import { PowerInhibitor } from './power-inhibitor.ts' import { TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardEventPayload, type TaskBoardSnapshot } from './protocol.ts' import type { TaskPermission } from './core/handover.ts' const SESSION_POLL_MS = 5_000 const SCHEDULE_TICK_MS = 30_000 const RESUME_GAP_MS = SCHEDULE_TICK_MS + 15_000 export class TaskBoardHostService { readonly ledger: HostTaskLedger readonly runner: HostExecutionRunner readonly power: PowerInhibitor private readonly listeners = new Set<() => void>() private timers: Array> = [] private lastScheduleTick: number | undefined private disposed = false private pollInFlight = false private tickInFlight = false private active = true /** * Ids the last roster poll saw as present and idle; undefined while the * roster is unknown. Session reuse (issue #1419) requires this positive * evidence, so a launch before the first successful poll mints a fresh * conversation instead of prompting into a session it cannot see. */ private idleSessionIds: ReadonlySet | undefined private preventIdleSleep = false private lastPowerJson = '' private readonly now: () => number constructor(gateway: TypertGateway, options: { ledger?: HostTaskLedger power?: PowerInhibitor now?: () => number commandDispatcher?: SessionCommandDispatcher workspaceRegistry?: TaskBoardWorkspaceRegistry sessionDefaultPermission?: TaskPermission } = {}) { this.ledger = options.ledger ?? new HostTaskLedger(undefined, undefined, { sessionDefaultPermission: options.sessionDefaultPermission }) this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry) this.power = options.power ?? new PowerInhibitor() this.now = options.now ?? Date.now installStreamErrorGuards() this.ledger.subscribe(() => { this.syncPowerReasons() this.emit() }) this.power.subscribe(() => { // updateReasons emits on every poll tick even when nothing changed; // gate on the actual snapshot so the 5 s heartbeat does not push an // empty SSE frame per tab forever. const json = JSON.stringify(this.power.snapshot()) if (json === this.lastPowerJson) return this.lastPowerJson = json this.emit() }) } start(): void { if (this.disposed || this.timers.length > 0) return this.syncPowerReasons() this.timers.push(setInterval(() => { this.schedulePoll() }, SESSION_POLL_MS)) this.timers.push(setInterval(() => { this.scheduleTick(false) }, SCHEDULE_TICK_MS)) this.schedulePoll() this.scheduleTick(true) } setConfiguration(active: boolean, preventIdleSleep: boolean): void { const resumed = !this.active && active this.active = active this.preventIdleSleep = preventIdleSleep if (resumed) { const current = this.power.snapshot() this.power.updateReasons({ runningSessions: current.runningSessions, armedSchedules: this.armedSchedules(), sessionStateKnown: false, }) } this.power.setEnabled(active && preventIdleSleep) if (resumed) { this.schedulePoll() this.scheduleTick(true) } this.emit() } snapshot(): TaskBoardSnapshot { const state = this.ledger.state() return { schemaVersion: TASK_BOARD_SCHEMA_VERSION, revision: state.revision, tasks: state.tasks, scheduler: state.scheduler, power: this.power.snapshot(), sessionDefaultPermission: this.ledger.sessionDefaultPermission, } } /** SSE frame payload; deliberately skips the tasks deep-clone of {@link snapshot}. */ eventPayload(): TaskBoardEventPayload { const { revision, scheduler } = this.ledger.summary() return { revision, scheduler, power: this.power.snapshot() } } subscribe(listener: () => void): () => void { this.listeners.add(listener) return () => { this.listeners.delete(listener) } } apply(requestId: string, action: TaskBoardAction, initiator?: string): TaskBoardSnapshot { if (!this.active) throw new Error('task board is disabled') const result = this.ledger.applyRequest(requestId, action, initiator) if (result.run !== undefined) this.scheduleLaunch(result.run) return { schemaVersion: TASK_BOARD_SCHEMA_VERSION, revision: result.state.revision, tasks: result.state.tasks, scheduler: result.state.scheduler, power: this.power.snapshot(), } } dispose(): void { this.disposed = true for (const timer of this.timers.splice(0)) clearInterval(timer) this.power.dispose() this.ledger.dispose() this.listeners.clear() } private async launch(opened: OpenedRun): Promise { try { const reuseSessionId = reusableSessionId(opened.task, this.idleSessionIds) const sessionId = await this.runner.launch(opened.task, reuseSessionId === undefined ? {} : { reuseSessionId }) this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId) } catch (error) { if (error instanceof SessionLaunchError) { this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId) } this.ledger.settle(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error)) } } private async pollSessions(): Promise { if (this.disposed) return if (!this.active && this.ledger.runtimeView().openExecutions.length === 0) return const running = await this.runner.listRunning() const previous = this.power.snapshot() if (!running.known) { this.idleSessionIds = undefined this.power.updateReasons({ runningSessions: previous.runningSessions, armedSchedules: this.ledger.armedScheduleCount(), sessionStateKnown: false, }) return } this.idleSessionIds = new Set(running.items.filter(item => !item.running).map(item => item.sessionId)) // Read after the RPC so executions attached while it was in flight are // included in this pass, matching the former full-state snapshot timing. const runtime = this.ledger.runtimeView() this.power.updateReasons({ runningSessions: running.count, armedSchedules: runtime.armedSchedules, sessionStateKnown: true, }) // No unconditional emit here: real changes already emit through the // ledger subscription (settles) and the gated power listener above. await this.reconcileExecutions(running.items, runtime.openExecutions) } /** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */ private async reconcileExecutions( sessions: readonly SessionSummary[], executions: readonly OpenExecutionReference[], ): Promise { for (const execution of executions) { if (execution.sessionId === undefined) continue try { const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions) if (result.outcome === 'pending') continue this.ledger.settle(execution.taskId, execution.executionId, result.outcome, 'error' in result ? result.error : undefined) } catch { // A transient inspection failure never settles a running execution. } } } private async tickSchedule(first: boolean): Promise { if (this.disposed || !this.active) return const now = this.now() const recovered = first || (this.lastScheduleTick !== undefined && now - this.lastScheduleTick > RESUME_GAP_MS) this.lastScheduleTick = now this.ledger.setScheduler({ lastTickAt: now }) if (recovered) { this.ledger.skipMissed(now) return } for (const schedule of this.ledger.dueSchedules(now)) { const next = nextRunAtMs(schedule.cron, schedule.nextRunAt) const opened = this.ledger.openScheduled(schedule.taskId, next, now) if (opened !== undefined) this.scheduleLaunch(opened) } } private armedSchedules(): number { return this.ledger.armedScheduleCount() } private scheduleLaunch(opened: OpenedRun): void { void this.launch(opened).catch(error => { safeConsoleError('[dsh-task-board] execution launch settlement failed', error) }) } private schedulePoll(): void { if (this.pollInFlight || this.disposed) return this.pollInFlight = true void this.pollSessions().catch(error => { safeConsoleError('[dsh-task-board] session polling failed', error) }).finally(() => { this.pollInFlight = false }) } private scheduleTick(first: boolean): void { if (this.tickInFlight || this.disposed) return this.tickInFlight = true void this.tickSchedule(first).catch(error => { safeConsoleError('[dsh-task-board] scheduler tick failed', error) }).finally(() => { this.tickInFlight = false }) } private syncPowerReasons(): void { const current = this.power.snapshot() this.power.updateReasons({ runningSessions: current.runningSessions, armedSchedules: this.armedSchedules(), sessionStateKnown: current.sessionStateKnown, }) this.power.setEnabled(this.active && this.preventIdleSleep) } private emit(): void { for (const listener of [...this.listeners]) listener() } } /** * Install stream error listeners on process.stderr and process.stdout so that * transient write failures (e.g. ENOSPC when the disk is full, or EPIPE on a * closed pipe) never emit unhandled 'error' events that kill the Node.js host process. */ export function installStreamErrorGuards(): void { for (const stream of [process.stderr, process.stdout]) { if (stream && typeof stream.on === 'function') { const hasErrorListener = typeof stream.listenerCount === 'function' && stream.listenerCount('error') > 0 if (!hasErrorListener) { stream.on('error', () => { // Swallow write stream errors to keep the host process alive }) } } } } /** * Defensively log to console.error without letting stderr write failures * (e.g. ENOSPC from SyncWriteStream on redirected logs) crash the host process. */ export function safeConsoleError(message: string, ...args: unknown[]): void { try { console.error(message, ...args) } catch { // Best-effort stderr write; ignore write errors when stderr stream fails } }