import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { dshHome } from './dsh-home.ts' import { isValidCron, nextRunAtMs } from './core/schedule.ts' import { isTaskRecord, parseLedger } from './core/store.ts' import { canMoveManually, retainRecentExecutions, settleExecution, startExecution, withStatus, type ExecutionRecord, type TaskRecord } from './core/tasks.ts' import { applyArchiveTask, applyRestoreTask } from './core/use-cases/task-archive.ts' import { applyCreateTask } from './core/use-cases/task-create.ts' import { applyDeleteTask } from './core/use-cases/task-delete.ts' import { applySetSchedule, applyScheduleNextRun } from './core/use-cases/task-schedule.ts' import { applyUpdateTask, canEditTaskContent, hasContentPatch } from './core/use-cases/task-update.ts' import { TASK_BOARD_LEGACY_SCHEMA_VERSION, TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardSchedulerSnapshot } from './protocol.ts' import { DEFAULT_SESSION_PERMISSION, requiresPermissionConfirmation, type TaskPermission } from './core/handover.ts' interface PersistedScheduler extends TaskBoardSchedulerSnapshot { importedSources?: string[] } interface PersistedRequest { requestId: string fingerprint: string } /** On-disk document of any schema generation (schemaVersion untyped until the load branches decide). */ type ParsedLedgerDocument = Omit, 'schemaVersion'> & { schemaVersion?: unknown } interface LedgerDocument { schemaVersion: typeof TASK_BOARD_SCHEMA_VERSION revision: number tasks: TaskRecord[] scheduler: PersistedScheduler recentRequests: PersistedRequest[] } export interface LedgerState { revision: number tasks: TaskRecord[] scheduler: TaskBoardSchedulerSnapshot } export interface OpenedRun { task: TaskRecord execution: ExecutionRecord } /** Minimal value copy used by the Host session monitor. */ export interface OpenExecutionReference { readonly taskId: string readonly executionId: string readonly sessionId: string | undefined readonly startedAt: number } /** Minimal value copy used by the Host scheduler. */ export interface DueScheduleReference { readonly taskId: string readonly cron: string readonly nextRunAt: number } /** Derived runtime data for one session-poll pass. */ export interface LedgerRuntimeView { readonly armedSchedules: number readonly openExecutions: readonly OpenExecutionReference[] } const MAX_REQUEST_CACHE = 256 interface CachedRequest { fingerprint: string } function timeZone(): string { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'local' } function cloneTasks(tasks: readonly TaskRecord[]): TaskRecord[] { return JSON.parse(JSON.stringify(tasks)) as TaskRecord[] } function hasOpenExecution(task: TaskRecord): boolean { return task.executions.some(execution => execution.endedAt === undefined) } /** * Process states that are dead but still occupy the PID table: `Z` (zombie) * and `X` (dead, being reaped). `process.kill(pid, 0)` reports such PIDs as * alive, so a crash leftover whose child was never reaped would otherwise be * mistaken for a live owner and block ledger startup forever. */ const DEAD_STATES = new Set(['Z', 'X']) /** * Best-effort single-letter process state ('R','S','D','Z',...) or undefined * when no probe is available on this platform. Linux reads /proc//stat * directly (no subprocess); other POSIX shells out to `ps -o stat=`; Windows * has no zombie state, so it returns undefined and the kill(0) probe alone * is authoritative there. */ export function processState(pid: number): string | undefined { if (process.platform === 'linux') { try { const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') const end = stat.lastIndexOf(')') if (end === -1) return undefined return stat.slice(end + 2).split(' ')[0] || undefined } catch { return undefined // no such process (or unreadable) } } if (process.platform === 'win32') return undefined try { const probe = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { timeout: PROCESS_PROBE_TIMEOUT_MS }) if (probe.status !== 0 || probe.stdout.length === 0) return undefined const state = probe.stdout.toString('utf8').trim() return state.length > 0 ? state[0] : undefined } catch { return undefined } } export function processIsAlive(pid: number): boolean { if (!Number.isSafeInteger(pid) || pid <= 0) return false const state = processState(pid) if (state !== undefined && DEAD_STATES.has(state)) return false try { process.kill(pid, 0) return true } catch (error) { return (error as NodeJS.ErrnoException).code !== 'ESRCH' } } const PROCESS_PROBE_TIMEOUT_MS = 3000 /** * The CIM fallback pays a WMI cold start, so it gets a wider budget than the * direct `Get-Process` read it backs up. */ const CIM_PROBE_TIMEOUT_MS = 8000 let ownStartTime: number | undefined let ownStartTimeResolved = false /** * Exact process start time (Unix epoch ms) on Linux, read straight from * /proc (field 22 = start ticks since boot, btime = boot epoch seconds). * No subprocess and no rounding, so the recorded `startedAt` from a previous * boot compares exactly against the live process identity. */ function linuxStartTimeMs(pid: number): number | undefined { try { const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') const end = stat.lastIndexOf(')') if (end === -1) return undefined const ticks = Number(stat.slice(end + 2).split(' ')[19]) if (!Number.isFinite(ticks)) return undefined const bootMatch = /^btime\s+(\d+)/m.exec(readFileSync('/proc/stat', 'utf8')) if (bootMatch === null) return undefined const btime = Number(bootMatch[1]) if (!Number.isFinite(btime)) return undefined return btime * 1000 + (ticks * 1000) / 100 // USER_HZ is 100 on Linux } catch { return undefined } } /** Runs one PowerShell script and returns its trimmed stdout. */ export type PowerShellProbe = (script: string, timeoutMs: number) => string | undefined /** Default probe: one hidden, profile-free PowerShell process per script. */ const runPowerShellProbe: PowerShellProbe = (script, timeoutMs) => { const probe = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { timeout: timeoutMs, windowsHide: true, }) if (probe.status !== 0 || probe.stdout.length === 0) return undefined return probe.stdout.toString('utf8').trim() } /** The epoch-millisecond reading a probe printed, or undefined when unusable. */ function parseProbeEpochMs(raw: string | undefined): number | undefined { if (raw === undefined || raw.trim() === '') return undefined const started = Number(raw.trim()) return Number.isFinite(started) ? started : undefined } /** Start time through Get-Process: precise, but empty for protected processes. */ function getProcessStartScript(pid: number): string { return '[DateTimeOffset]::FromFileTime((Get-Process -Id ' + String(pid) + ' -ErrorAction SilentlyContinue).StartTime.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()' } /** Start time through Win32_Process: readable for System/svchost too. */ function cimStartScript(pid: number): string { return '$p=Get-CimInstance Win32_Process -Filter "ProcessId=' + String(pid) + '" -ErrorAction SilentlyContinue;if($p -ne $null){[DateTimeOffset]::FromFileTime($p.CreationDate.ToUniversalTime().ToFileTime()).ToUnixTimeMilliseconds()}' } /** * Windows start time (Unix epoch ms) of a live process. * * `Get-Process` is the precise first choice, but an unprivileged caller cannot * read `.StartTime` for a protected process (System, svchost): the property is * empty, so the probe returns nothing. Without a fallback, a crash leftover * lock whose PID was reused by such a process could never be proven stale and * blocked every startup until the lock was deleted by hand (issue #1629). * Win32_Process through CIM reports the same CreationDate for those processes * at the same millisecond precision, so it is the second identity source. The * probe is injectable so the fallback chain is testable off Windows. */ export function win32StartTimeMs(pid: number, probe: PowerShellProbe = runPowerShellProbe): number | undefined { // The pid is interpolated into a PowerShell script, so it must be a plain // positive integer before any probe runs. if (!Number.isSafeInteger(pid) || pid <= 0) return undefined const direct = parseProbeEpochMs(probe(getProcessStartScript(pid), PROCESS_PROBE_TIMEOUT_MS)) if (direct !== undefined) return direct return parseProbeEpochMs(probe(cimStartScript(pid), CIM_PROBE_TIMEOUT_MS)) } /** * Best-effort start time (Unix epoch ms) of a live process. Used to prove * whether the ledger lock really belongs to the PID recorded in it, so a * crash leftover whose PID was reused by an unrelated process (issue #786) * is detected as stale instead of blocking startup forever. Returns * undefined when the platform probe is unavailable; callers fail closed. */ function processStartTimeMs(pid: number): number | undefined { if (process.platform === 'linux') return linuxStartTimeMs(pid) if (process.platform === 'win32') return win32StartTimeMs(pid) // Other POSIX (macOS...): ps lstart with a forced English locale, falling // back to the elapsed-seconds column when lstart cannot be parsed. const env = { ...process.env, LC_ALL: 'C' } const probe = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { timeout: PROCESS_PROBE_TIMEOUT_MS, env }) if (probe.status === 0 && probe.stdout.length > 0) { const started = Date.parse(probe.stdout.toString('utf8').trim()) if (Number.isFinite(started)) return started } const elapsed = spawnSync('ps', ['-o', 'etimes=', '-p', String(pid)], { timeout: PROCESS_PROBE_TIMEOUT_MS, env }) if (elapsed.status !== 0 || elapsed.stdout.length === 0) return undefined const seconds = Number(elapsed.stdout.toString('utf8').trim()) if (!Number.isFinite(seconds)) return undefined return Date.now() - seconds * 1000 } function ownProcessStartTimeMs(): number | undefined { if (!ownStartTimeResolved) { ownStartTimeResolved = true ownStartTime = processStartTimeMs(process.pid) } return ownStartTime } /** * Bounded tolerance for legacy lock records. Locks written before the * ms-precise probe recorded `startedAt` from `ps -o lstart=` at whole-second * resolution; probing the SAME live process exactly (via /proc) then differs * in the sub-second remainder. Treating that as PID reuse would steal a live * owner's lock during a rolling upgrade and start a second ledger writer. * Records written by the ms-precise probe carry `probe: 'exact'` and are * compared strictly; anything else (older locks, second-granularity probes) * falls back to this bounded tolerance. */ const LEGACY_START_TOLERANCE_MS = 2000 /** * How long an unreadable lock must sit untouched before it may be reclaimed. * The owner writes and fsyncs its record immediately after creating the file * with O_EXCL, so a lock that cannot be parsed may still be mid-write by a * live owner; only one that has been unreadable for longer than any write can * take is treated as an unclean-shutdown leftover (issue #1528: a 0-byte lock * kept the Host half from mounting until it was deleted by hand). */ const UNREADABLE_LOCK_GRACE_MS = 60_000 /** Whether the recorded start time proves the recorded PID is another process. */ function startTimeMismatch(recorded: number, actual: number, exact: boolean): boolean { return exact ? recorded !== actual : Math.abs(recorded - actual) > LEGACY_START_TOLERANCE_MS } function betterExecution(a: ExecutionRecord, b: ExecutionRecord): ExecutionRecord { if (a.endedAt === undefined && b.endedAt !== undefined) return b if (b.endedAt === undefined && a.endedAt !== undefined) return a return (b.endedAt ?? b.startedAt) >= (a.endedAt ?? a.startedAt) ? b : a } function mergeTask(a: TaskRecord, b: TaskRecord): TaskRecord { // Existing Host state wins ties so an equally old browser backup cannot // roll authoritative fields back during multi-browser v1 migration. const newer = b.updatedAt > a.updatedAt ? b : a const byId = new Map() for (const entry of [...a.executions, ...b.executions]) { const previous = byId.get(entry.id) byId.set(entry.id, previous === undefined ? entry : betterExecution(previous, entry)) } const executions = [...byId.values()].sort((x, y) => x.startedAt - y.startedAt) return { ...newer, executions: retainRecentExecutions(executions) } } function parseHostTasks(values: readonly unknown[]): TaskRecord[] { const rawById = new Map>() for (const value of values) { if (typeof value !== 'object' || value === null) continue const raw = value as Record if (typeof raw.id === 'string') rawById.set(raw.id, raw) } return parseLedger(JSON.stringify(values)).map(task => { const rawSchedule = rawById.get(task.id)?.schedule if (typeof rawSchedule !== 'object' || rawSchedule === null) return task const schedule = rawSchedule as Record if (typeof schedule.cron !== 'string' || isValidCron(schedule.cron)) return task return { ...task, schedule: { enabled: false, cron: schedule.cron, nextRunAt: undefined, lastTriggeredAt: typeof schedule.lastTriggeredAt === 'number' && Number.isFinite(schedule.lastTriggeredAt) ? schedule.lastTriggeredAt : undefined, }, } }) } export class HostTaskLedger { private document: LedgerDocument private readonly listeners = new Set<() => void>() private readonly requestCache = new Map() private readonly lockToken = crypto.randomUUID() private lockFd: number | undefined readonly file: string readonly lockFile: string /** Small sidecar for the 30 s scheduler heartbeat (lastTickAt only). */ readonly schedulerFile: string /** Session-default permission the confirmation gate compares against. */ readonly sessionDefaultPermission: TaskPermission constructor(dir: string = join(dshHome(), 'task-board'), private readonly now: () => number = Date.now, options: { sessionDefaultPermission?: TaskPermission } = {}) { this.sessionDefaultPermission = options.sessionDefaultPermission ?? DEFAULT_SESSION_PERMISSION mkdirSync(dir, { recursive: true }) this.file = join(dir, 'ledger-v2.json') this.lockFile = join(dir, 'ledger-v2.lock') this.schedulerFile = join(dir, 'scheduler-v2.json') this.cleanStaleTemporaryFiles(dir) this.lockFd = this.acquireLock() try { this.document = this.load(dir) for (const request of this.document.recentRequests) { this.requestCache.set(request.requestId, { fingerprint: request.fingerprint }) } this.repairSchedules(true) this.reconcileInterruptedStarts() // Persist a freshly generated ledger identity and any recovery error // immediately, even when there are no tasks to trigger a later action. this.commit(false) } catch (error) { this.dispose() throw error } } /** Remove leftover *.tmp-* files from previous crashes or interrupted writes. */ private cleanStaleTemporaryFiles(dir: string): void { try { const entries = readdirSync(dir) for (const entry of entries) { if (entry.includes('.tmp-')) { try { unlinkSync(join(dir, entry)) } catch { // Best-effort cleanup } } } } catch { // Directory may not exist yet or cannot be read } } /** Revision + scheduler without any task cloning; feeds the SSE event frame. */ summary(): { revision: number; scheduler: TaskBoardSchedulerSnapshot } { const { importedSources: _imports, ...scheduler } = this.document.scheduler return { revision: this.document.revision, scheduler: { ...scheduler } } } state(): LedgerState { const { revision, scheduler } = this.summary() return { revision, tasks: cloneTasks(this.document.tasks), scheduler } } /** * Runtime-only projection for the 5 s Host poll. It copies just primitive * identifiers and timestamps, never the complete task/execution history or * an authoritative mutable object from the ledger. */ runtimeView(): LedgerRuntimeView { let armedSchedules = 0 const openExecutions: OpenExecutionReference[] = [] for (const task of this.document.tasks) { if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1 for (const execution of task.executions) { if (execution.endedAt !== undefined) continue openExecutions.push({ taskId: task.id, executionId: execution.id, sessionId: execution.sessionId, startedAt: execution.startedAt, }) } } return { armedSchedules, openExecutions } } /** Count armed, non-archived schedules without cloning task histories. */ armedScheduleCount(): number { let count = 0 for (const task of this.document.tasks) { if (task.archivedAt === undefined && task.schedule?.enabled === true) count += 1 } return count } /** Return value-only references for schedules due at the supplied Host time. */ dueSchedules(now: number): DueScheduleReference[] { const due: DueScheduleReference[] = [] for (const task of this.document.tasks) { if (task.archivedAt !== undefined) continue const schedule = task.schedule if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue due.push({ taskId: task.id, cron: schedule.cron, nextRunAt: schedule.nextRunAt }) } return due } subscribe(listener: () => void): () => void { this.listeners.add(listener) return () => { this.listeners.delete(listener) } } dispose(): void { const fd = this.lockFd if (fd === undefined) return this.lockFd = undefined closeSync(fd) try { const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { token?: unknown } if (owner.token === this.lockToken) unlinkSync(this.lockFile) } catch { // A missing or externally replaced lock must not be removed blindly. } } applyRequest( requestId: string, action: TaskBoardAction, initiator?: string, ): { state: LedgerState; run?: OpenedRun } { const fingerprint = createHash('sha256').update(JSON.stringify(action)).digest('hex') const cached = this.requestCache.get(requestId) if (cached !== undefined) { if (cached.fingerprint !== fingerprint) throw new Error('request id was reused with a different action') return { state: this.state() } } // Add the fingerprint before apply(): successful actions persist it in the // same atomic ledger write as their state transition. this.requestCache.set(requestId, { fingerprint }) while (this.requestCache.size > MAX_REQUEST_CACHE) this.requestCache.delete(this.requestCache.keys().next().value as string) this.syncRecentRequests() try { return this.apply(action, initiator) } catch (error) { this.requestCache.delete(requestId) this.syncRecentRequests() throw error } } openScheduled(taskId: string, nextRunAt: number | undefined, triggeredAt: number): OpenedRun | undefined { const task = this.document.tasks.find(item => item.id === taskId) if (task === undefined || task.archivedAt !== undefined) return undefined if (requiresPermissionConfirmation(task, this.sessionDefaultPermission)) { // An unconfirmed above-default permission must never run unattended: // cron refuses the card and rolls to the next occurrence, exactly // like the already-running refusal. this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)] this.commit() return undefined } if (task.status === 'running' || hasOpenExecution(task)) { this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, task.schedule?.lastTriggeredAt, triggeredAt)] this.commit() return undefined } const opened = startExecution(task, triggeredAt, crypto.randomUUID()) this.document.tasks = this.document.tasks.map(item => item.id === taskId ? opened.task : item) this.document.tasks = [...applyScheduleNextRun(this.document.tasks, taskId, nextRunAt, triggeredAt, triggeredAt)] this.commit() return opened } skipMissed(now: number): void { let changed = false this.document.tasks = this.document.tasks.map(task => { const schedule = task.schedule if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) return task changed = true return { ...task, schedule: { ...schedule, nextRunAt: nextRunAtMs(schedule.cron, now) }, updatedAt: now } }) if (changed) this.commit() } setScheduler(patch: Partial): void { this.document.scheduler = { ...this.document.scheduler, ...patch } // The 30 s heartbeat only moves lastTickAt; rewriting the whole ledger // for it made idle idle cost O(ledger bytes) every tick. Persist it to a // tiny sidecar instead; any other patch still goes through the full // atomic commit. if (patch.lastTickAt !== undefined && Object.keys(patch).every(key => key === 'lastTickAt')) { try { this.writeSchedulerSidecar() } catch (error) { if ((error as NodeJS.ErrnoException)?.code === 'ENOSPC') { // Disk is full; sidecar persistence fails, but in-memory heartbeat // remains updated. Swallow to prevent unhandled log cascade crashes. return } throw error } return } this.commit(false) } attachSession(taskId: string, executionId: string, sessionId: string): void { const now = this.now() this.document.tasks = this.document.tasks.map(task => task.id !== taskId ? task : { ...task, updatedAt: now, executions: task.executions.map(entry => entry.id === executionId ? { ...entry, sessionId } : entry), }) this.commit() } settle(taskId: string, executionId: string, outcome: 'succeeded' | 'failed' | 'cancelled', error?: string): void { this.document.tasks = this.document.tasks.map(task => task.id === taskId ? settleExecution(task, executionId, outcome, this.now(), error) : task) this.commit() } private apply(action: TaskBoardAction, initiator?: string): { state: LedgerState; run?: OpenedRun } { const now = this.now() let run: OpenedRun | undefined switch (action.kind) { case 'import': { const sources = new Set(this.document.scheduler.importedSources ?? []) if (sources.has(action.sourceId)) return { state: this.state() } const invalidScheduleIds = action.tasks .filter(task => task.schedule !== undefined && !isValidCron(task.schedule.cron)) .map(task => task.id) const incoming = parseHostTasks(action.tasks) const merged = new Map(this.document.tasks.map(task => [task.id, task])) for (const task of incoming) merged.set(task.id, merged.has(task.id) ? mergeTask(merged.get(task.id)!, task) : task) this.document.tasks = [...merged.values()] this.document.scheduler.importedSources = [...sources, action.sourceId] this.document.scheduler.error = invalidScheduleIds.length === 0 ? undefined : `invalid cron disabled for task(s): ${invalidScheduleIds.join(', ')}` this.repairSchedules(true, false) this.reconcileInterruptedStarts(false) break } case 'create': { if (this.document.tasks.some(task => task.id === action.id)) throw new Error('task id already exists') if (action.input.schedule?.enabled === true && (!isValidCron(action.input.schedule.cron) || nextRunAtMs(action.input.schedule.cron, now) === undefined)) { throw new Error('invalid schedule') } const input = action.input.freeze === undefined || initiator === undefined || initiator === '' ? action.input : { ...action.input, freeze: { ...action.input.freeze, frozenBy: initiator } } const result = applyCreateTask(this.document.tasks, input, now, action.id) if (result.task === undefined) throw new Error('invalid task') this.document.tasks = [...result.tasks] break } case 'update': { const task = this.document.tasks.find(task => task.id === action.taskId) if (task === undefined) throw new Error('task not found') if (task.archivedAt !== undefined) throw new Error('archived task is read-only') // The task content (title/description/prompt) is the record of what // was planned; once an execution started it must not change under a // running session or an executed history. Execution targets stay // editable (they only affect future runs). if (hasContentPatch(action.patch) && !canEditTaskContent(task)) { throw new Error('task has already been executed') } if ('title' in action.patch && action.patch.title?.trim() === '') throw new Error('title is required') // A replaced snapshot is re-stamped with the updating session (the // initiator), so a swapped freeze cannot keep the old author stamp. const patch = action.patch.freeze === null || action.patch.freeze === undefined || initiator === undefined || initiator === '' ? action.patch : { ...action.patch, freeze: { ...action.patch.freeze, frozenBy: initiator } } this.document.tasks = [...applyUpdateTask(this.document.tasks, action.taskId, patch, now)] break } case 'delete': { const task = this.document.tasks.find(task => task.id === action.taskId) if (task === undefined) throw new Error('task not found') if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be deleted') } this.document.tasks = [...applyDeleteTask(this.document.tasks, undefined, action.taskId).tasks] break case 'move': { const task = this.document.tasks.find(item => item.id === action.taskId) if (task === undefined) throw new Error('task not found') if (task.archivedAt !== undefined) throw new Error('archived task is read-only') if (task.status === 'running' || hasOpenExecution(task)) throw new Error('running task cannot be moved') if (!canMoveManually(task.status, action.status)) throw new Error('invalid manual status') this.document.tasks = this.document.tasks.map(item => item.id === action.taskId ? withStatus(item, action.status, now) : item) break } case 'archive': { const result = applyArchiveTask(this.document.tasks, action.taskId, now) if (!result.archived) throw new Error('task cannot be archived') this.document.tasks = [...result.tasks] break } case 'restore': { const result = applyRestoreTask(this.document.tasks, action.taskId, now) if (!result.archived) throw new Error('task is not archived') this.document.tasks = [...result.tasks] break } case 'confirm-permission': { const task = this.document.tasks.find(item => item.id === action.taskId) if (task === undefined) throw new Error('task not found') if (task.permissionConfirmedAt !== undefined) break this.document.tasks = this.document.tasks.map(item => item.id === action.taskId ? { ...item, permissionConfirmedAt: now, updatedAt: now } : item) break } case 'set-schedule': { const task = this.document.tasks.find(task => task.id === action.taskId) if (task?.archivedAt !== undefined) throw new Error('archived task is read-only') const result = applySetSchedule(this.document.tasks, action.taskId, action.patch, now) if (!result.applied) throw new Error('invalid schedule') this.document.tasks = [...result.tasks] break } case 'rerun': case 'run': { const task = this.document.tasks.find(item => item.id === action.taskId) if (task?.archivedAt !== undefined) throw new Error('archived task is read-only') if (task === undefined || task.status === 'running' || hasOpenExecution(task)) throw new Error('task is already running or missing') if (requiresPermissionConfirmation(task, this.sessionDefaultPermission)) { throw new Error(`confirmation-required: the effective permission is above the session default (${this.sessionDefaultPermission}); confirm the card's permission binding first`) } const base = action.kind === 'rerun' ? withStatus(task, 'todo', now) : task run = startExecution(base, now, crypto.randomUUID(), initiator) this.document.tasks = this.document.tasks.map(item => item.id === task.id ? run!.task : item) break } } this.commit() return { state: this.state(), ...(run === undefined ? {} : { run }) } } private repairSchedules(skipPast: boolean, persist = true): void { const now = this.now() let changed = false this.document.tasks = this.document.tasks.map(task => { const schedule = task.schedule if (schedule === undefined || !schedule.enabled) return task if (!skipPast && schedule.nextRunAt !== undefined) return task const next = nextRunAtMs(schedule.cron, now) if (next === undefined) { changed = true this.document.scheduler.error = `invalid cron disabled for task: ${task.id}` return { ...task, schedule: { ...schedule, enabled: false, nextRunAt: undefined }, updatedAt: now } } if (schedule.nextRunAt === next) return task changed = true return { ...task, schedule: { ...schedule, nextRunAt: next }, updatedAt: now } }) if (changed && persist) this.commit() } private reconcileInterruptedStarts(persist = true): void { const now = this.now() let changed = false this.document.tasks = this.document.tasks.map(task => { if (task.status !== 'running') return task const execution = task.executions.at(-1) if (execution === undefined || execution.endedAt !== undefined || execution.sessionId !== undefined) return task changed = true return settleExecution(task, execution.id, 'cancelled', now, 'host restarted before the execution session was recorded') }) if (changed && persist) this.commit() } /** * Field-preserving v2 to v3 migration. v3 adds no fields yet, so the * migration reuses the v3 normalization, but it first proves every task * row is structurally valid: a v2 document that would silently drop or * coerce rows fails loudly instead (no quarantined-empty restart). */ private migrateLegacyDocument(parsed: ParsedLedgerDocument): LedgerDocument { if (!Array.isArray(parsed.tasks) || !parsed.tasks.every(row => isTaskRecord(row))) { throw new Error('v2 document contains structurally invalid task rows') } return this.normalizeDocument(parsed) } private load(dir: string): LedgerDocument { const existed = existsSync(this.file) // schemaVersion stays unknown-typed here: on-disk documents may be v2 // (legacy), v3, or any future/invalid value the branches below sort out. let parsed: ParsedLedgerDocument try { parsed = JSON.parse(readFileSync(this.file, 'utf8')) as ParsedLedgerDocument } catch (error) { return this.recoverCorrupt(dir, existed, error) } if (parsed.schemaVersion === TASK_BOARD_LEGACY_SCHEMA_VERSION) { try { return this.migrateLegacyDocument(parsed) } catch (error) { // Migration failure is explicit: the original v2 file stays in place // for manual recovery and the ledger refuses to start (fail closed). throw new Error(`ledger v2 to v3 migration failed; original file kept at ${this.file}: ${error instanceof Error ? error.message : String(error)}`) } } try { if (parsed.schemaVersion !== TASK_BOARD_SCHEMA_VERSION || !Array.isArray(parsed.tasks)) throw new Error('unsupported ledger schema') return this.normalizeDocument(parsed) } catch (error) { return this.recoverCorrupt(dir, existed, error) } } private normalizeDocument(parsed: ParsedLedgerDocument): LedgerDocument { const tasks = parseHostTasks(parsed.tasks as readonly unknown[]).map(task => ({ ...task, executions: retainRecentExecutions(task.executions) })) const invalidScheduleIds = (parsed.tasks as unknown[]).flatMap(value => { if (typeof value !== 'object' || value === null) return [] const row = value as { id?: unknown; schedule?: unknown } if (typeof row.schedule !== 'object' || row.schedule === null) return [] const cron = (row.schedule as { cron?: unknown }).cron return typeof cron !== 'string' || !isValidCron(cron) ? [typeof row.id === 'string' ? row.id : 'unknown'] : [] }) const documentLastTickAt = typeof parsed.scheduler?.lastTickAt === 'number' ? parsed.scheduler.lastTickAt : undefined const sidecarLastTickAt = this.readSchedulerSidecar() // A sidecar write can be newer than the last full commit (crash between // the two); lastTickAt only ever moves forward, so take the greater. const lastTickAt = sidecarLastTickAt === undefined || (documentLastTickAt !== undefined && documentLastTickAt >= sidecarLastTickAt) ? documentLastTickAt : sidecarLastTickAt return { schemaVersion: TASK_BOARD_SCHEMA_VERSION, revision: Number.isSafeInteger(parsed.revision) && (parsed.revision as number) >= 0 ? parsed.revision as number : 0, tasks, scheduler: { timeZone: timeZone(), ledgerId: typeof parsed.scheduler?.ledgerId === 'string' && parsed.scheduler.ledgerId !== '' ? parsed.scheduler.ledgerId : crypto.randomUUID(), ...(lastTickAt === undefined ? {} : { lastTickAt }), ...(typeof parsed.scheduler?.error === 'string' ? { error: parsed.scheduler.error } : {}), ...(invalidScheduleIds.length > 0 ? { error: `invalid cron disabled for task(s): ${invalidScheduleIds.join(', ')}` } : {}), ...(Array.isArray(parsed.scheduler?.importedSources) ? { importedSources: parsed.scheduler.importedSources.filter(x => typeof x === 'string') } : {}), }, recentRequests: Array.isArray(parsed.recentRequests) ? parsed.recentRequests.flatMap((entry) => { if (typeof entry !== 'object' || entry === null) return [] const request = entry as { requestId?: unknown; fingerprint?: unknown } return typeof request.requestId === 'string' && request.requestId !== '' && typeof request.fingerprint === 'string' ? [{ requestId: request.requestId, fingerprint: request.fingerprint }] : [] }).slice(-MAX_REQUEST_CACHE) : [], } } /** Quarantine an unreadable document and start from an empty ledger. */ private recoverCorrupt(dir: string, existed: boolean, error: unknown): LedgerDocument { if (existed) renameSync(this.file, `${this.file}.corrupt-${this.now()}-${process.pid}-${crypto.randomUUID()}`) mkdirSync(dir, { recursive: true }) return { schemaVersion: TASK_BOARD_SCHEMA_VERSION, revision: 0, tasks: [], scheduler: { timeZone: timeZone(), ledgerId: crypto.randomUUID(), ...(existed ? { error: `corrupt ledger was quarantined: ${error instanceof Error ? error.message : String(error)}` } : {}) }, recentRequests: [], } } private syncRecentRequests(): void { this.document.recentRequests = [...this.requestCache].map(([requestId, request]) => ({ requestId, fingerprint: request.fingerprint, })) } private readSchedulerSidecar(): number | undefined { try { const parsed = JSON.parse(readFileSync(this.schedulerFile, 'utf8')) as { lastTickAt?: unknown } return typeof parsed.lastTickAt === 'number' && Number.isFinite(parsed.lastTickAt) ? parsed.lastTickAt : undefined } catch { return undefined } } /** Atomic write of the scheduler heartbeat sidecar (0600, tmp + rename + fsync). */ private writeSchedulerSidecar(): void { const payload = JSON.stringify({ lastTickAt: this.document.scheduler.lastTickAt }) mkdirSync(dirname(this.schedulerFile), { recursive: true }) const tmp = `${this.schedulerFile}.tmp-${process.pid}` let fd: number | undefined try { fd = openSync(tmp, 'w', 0o600) writeFileSync(fd, payload, { encoding: 'utf8' }) fsyncSync(fd) closeSync(fd) fd = undefined try { chmodSync(tmp, 0o600) } catch { /* Windows ACLs own access */ } renameSync(tmp, this.schedulerFile) try { const dirFd = openSync(dirname(this.schedulerFile), 'r') try { fsyncSync(dirFd) } finally { closeSync(dirFd) } } catch { // Windows does not permit fsync on a directory handle; rename remains atomic. } } catch (error) { if (fd !== undefined) closeSync(fd) try { unlinkSync(tmp) } catch { /* best-effort temporary cleanup */ } throw error } this.notify() } private commit(bumpRevision = true): void { if (bumpRevision) this.document.revision += 1 mkdirSync(dirname(this.file), { recursive: true }) const tmp = `${this.file}.tmp-${process.pid}` let fd: number | undefined try { fd = openSync(tmp, 'w', 0o600) writeFileSync(fd, JSON.stringify(this.document, null, 2), { encoding: 'utf8' }) fsyncSync(fd) closeSync(fd) fd = undefined try { chmodSync(tmp, 0o600) } catch { /* Windows ACLs own access */ } renameSync(tmp, this.file) try { const dirFd = openSync(dirname(this.file), 'r') try { fsyncSync(dirFd) } finally { closeSync(dirFd) } } catch { // Windows does not permit fsync on a directory handle; rename remains atomic. } } catch (error) { if (fd !== undefined) closeSync(fd) try { unlinkSync(tmp) } catch { /* best-effort temporary cleanup */ } throw error } this.notify() } private notify(): void { for (const listener of [...this.listeners]) listener() } private acquireLock(): number { for (let attempt = 0; attempt < 2; attempt += 1) { try { const fd = openSync(this.lockFile, 'wx', 0o600) const startedAt = ownProcessStartTimeMs() // Linux /proc and Windows PowerShell probes are ms-precise; locks they // write are compared strictly. Other POSIX probes (ps) stay // second-granularity, so their records are compared with the bounded // legacy tolerance. const probe = process.platform === 'linux' || process.platform === 'win32' ? 'exact' : 'legacy' writeFileSync(fd, JSON.stringify({ pid: process.pid, token: this.lockToken, startedAt, probe }), { encoding: 'utf8' }) fsyncSync(fd) try { chmodSync(this.lockFile, 0o600) } catch { /* Windows ACLs own access */ } return fd } catch (error) { const code = (error as NodeJS.ErrnoException).code if (code !== 'EEXIST') throw error let pid: number | undefined let ownerStartedAt: number | undefined let ownerExact = false try { const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { pid?: unknown; startedAt?: unknown; probe?: unknown } if (typeof owner.pid === 'number') pid = owner.pid if (typeof owner.startedAt === 'number') ownerStartedAt = owner.startedAt ownerExact = owner.probe === 'exact' } catch { // A power-loss mid-write can leave an empty or truncated lock. Such a // lock still fails closed while it is fresh (a live owner may be // mid-write); once it is older than the grace window nothing can be // writing it, so the leftover is reclaimed instead of blocking every // later start until someone deletes it by hand (issue #1528). const age = (() => { try { return this.now() - statSync(this.lockFile).mtimeMs } catch { return Number.POSITIVE_INFINITY } })() if (age < UNREADABLE_LOCK_GRACE_MS) { throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}; if this is a leftover from an unclean shutdown and no other DSH host is running, remove it manually and retry`) } try { unlinkSync(this.lockFile) } catch (unlinkError) { if ((unlinkError as NodeJS.ErrnoException).code !== 'ENOENT') throw unlinkError } continue } if (pid !== undefined && processIsAlive(pid)) { const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid) // A reused PID is exposed when the live process identity no longer // matches the recorded one: either the recorded start time differs // beyond the probe's resolution (strict for ms-precise 'exact' // records, a bounded legacy tolerance for old second-granularity // records written by ps), or (legacy locks without a start time) // the lock file predates the live process and therefore cannot // have been written by it. Takeover is safe in both cases — the // original owner is gone. const staleReuse = actualStartedAt !== undefined && ( ownerStartedAt !== undefined ? startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact) : (() => { try { return statSync(this.lockFile).mtimeMs < actualStartedAt } catch { return true } })() ) if (!staleReuse) { const confirmedOwner = ownerStartedAt !== undefined && actualStartedAt !== undefined && !startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact) const hint = confirmedOwner ? '' : `; if this PID was reused after a crash and no other DSH host is running, remove ${this.lockFile} manually and retry` throw new Error(`task-board ledger is already owned by process ${pid}${hint}`) } } try { unlinkSync(this.lockFile) } catch (unlinkError) { if ((unlinkError as NodeJS.ErrnoException).code !== 'ENOENT') throw unlinkError } } } throw new Error(`task-board ledger lock could not be acquired: ${this.lockFile}`) } }