/** * Job registry — daemon-owned background jobs (task 139). * * WHY THIS EXISTS * Cumulus tried three times to make background work reliable by TELLING the * model how to do it: task 108 (the redirect cure), 109 (the two-leg * watcher+deadline rule), 116 (the worked one-liner in the content store). * All three are correct and all three still failed live, because the cure has * to be applied by the driver AND every child, and because no shell trick * escapes the third killer at all. This is task 115's lesson applied again: * stop prescribing, enforce at the seam. * * WHAT OWNING IT BUYS, BY CONSTRUCTION * 1. Turn end → SIGPIPE. The daemon spawns the job with NO pipes: stdout and * stderr are a file descriptor, stdin is /dev/null. There is nothing whose * read end can close, so SIGPIPE (task 108's entire failure mode) stops * being reachable rather than being cured. * 2. Interjection → SIGTERM. The job is not in the turn's process tree, so * killing the turn cannot reach it. * 3. Gateway reload → cgroup reap. NOT solved by spawning here — `detached` * gives a new session, and a session does not leave a cgroup. It needs * `KillMode=process` on the unit (shipped in setup.ts's template). What * this module guarantees regardless is that a job killed by a restart is * REPORTED as interrupted rather than silently vanishing. * * The exit code is written to disk BY THE JOB ITSELF, not read off the child * `exit` event. The event is authoritative while this process lives and is * unavailable after a restart; the file survives both, which is what makes * adoption able to distinguish "exited 7 while we were down" from "killed". */ export type JobStatus = 'running' | 'done' | 'failed' | 'cancelled' | 'interrupted'; export interface JobRecord { id: string; thread: string; label: string; command: string; cwd: string; pid?: number; /** * OS-level start token, guarding against pid reuse across a gateway restart. * A pid alone is not identity: on a busy box the number can belong to an * unrelated process by the time we adopt, and we would then report a live * job for something that died. Linux reads field 22 of /proc//stat * (start time in clock ticks); elsewhere it is absent and liveness degrades * to a bare kill(pid, 0). */ startToken?: string; startedAt: string; endedAt?: string; status: JobStatus; exitCode?: number; signal?: string; logPath: string; } export interface CreateJobRequest { thread: string; command: string; label?: string; cwd: string; } /** Max concurrently-running jobs for one thread. */ export declare const MAX_RUNNING_PER_THREAD = 5; /** Max concurrently-running jobs across all threads. */ export declare const MAX_RUNNING_TOTAL = 20; /** Finished records retained per thread; older ones are pruned with their logs. */ export declare const MAX_FINISHED_PER_THREAD = 25; /** Lines of log tail carried in the completion report. */ export declare const COMPLETION_TAIL_LINES = 40; /** How often adopted jobs (no live child handle) are checked for death. */ export declare const ADOPTION_POLL_MS = 5000; export interface ValidationResult { ok: boolean; error?: string; } /** * Validate a job request. Deliberately permissive about the command itself — * a thread that reaches this seam has already been checked for `Bash` * availability by the caller, so the command is no more privileged than what * it could already run. What is rejected is what would make the RECORD * unusable: an empty command, or a label that would corrupt the report. */ export declare function validateJobRequest(req: Partial): ValidationResult; /** Map a process outcome onto a durable status. */ export declare function classifyExit(exitCode: number | undefined, signal?: string): JobStatus; /** * Wrap the command so the job records its OWN exit status before leaving. * * An EXIT trap, NOT trailing statements. Trailing statements are unreachable * whenever the command ends in `exit N` (or `exec`) — which is ordinary in a * driver script, and which silently produced "interrupted" for jobs that had * in fact finished cleanly. A trap fires on both paths. `$?` inside it is the * status that triggered it, and nothing runs before the capture. * * The path arrives through the environment rather than interpolated, so no * quoting of ours can collide with the command's. * * Known limit: a command that installs its own EXIT trap replaces this one, and * that job reports as interrupted. Rare, and it degrades to the honest answer. */ export declare function wrapCommand(command: string): string; /** Read the OS start token for a pid (Linux only; undefined elsewhere). */ export declare function readStartToken(pid: number): string | undefined; /** * Is this pid still the process we started? * * `kill(pid, 0)` answers "does a process with this number exist", which is a * weaker question. When a start token was recorded we also require it to * match, so a recycled pid reads as dead rather than as our job. */ export declare function isProcessAlive(pid: number | undefined, startToken?: string): boolean; /** * The message the finished job sends back into its thread. * * Self-describing on purpose: it may arrive either as a fresh turn (idle) or * inside a batched queue drain (busy), and it has to read correctly both ways. * It carries the exit code and a log tail so the woken turn can DIAGNOSE * without re-running — task 109's "fire on outcome, not on success". */ export declare function formatCompletionMessage(job: JobRecord, logTail: string): string; /** Read the last N lines of a file, bounded so a huge log cannot be slurped. */ export declare function tailFile(filePath: string, lines: number, maxBytes?: number): string; /** * Trim finished records for a thread down to the retention cap. * Returns the records to keep and the ones evicted (whose files the caller * deletes) — pure so the retention rule is testable without a filesystem. */ export declare function pruneFinished(records: JobRecord[], thread: string, keep?: number): { kept: JobRecord[]; evicted: JobRecord[]; }; export interface JobRegistryOptions { /** Directory holding jobs.json and the per-job log/exit files. */ jobsDir?: string; log?: (msg: string, data?: Record) => void; /** * Deliver a completion report as a turn on the job's thread. Injected rather * than imported so the registry has no dependency on the HTTP server (and so * tests can observe delivery without spawning Claude). The daemon wires this * to server.ts's `deliverAgentTurn`, which is the SAME busy-gate + queue used * by the scheduler and every other inject path (tasks 100/120). */ deliver: (thread: string, text: string, sender: string) => void | Promise; pollIntervalMs?: number; } export interface JobRegistryHandle { create(req: CreateJobRequest): JobRecord; list(thread: string): JobRecord[]; get(thread: string, id: string): JobRecord | undefined; cancel(thread: string, id: string): boolean; tail(thread: string, id: string, lines?: number): string | undefined; /** * Re-key every record of `from` to `to` (task 186). Running jobs deliver to * the record's `thread` at completion, so this reaches them too. Returns the * number of records changed. */ rename(from: string, to: string): number; /** Adopt jobs recorded by a previous daemon process. Called once at startup. */ adopt(): void; stop(): void; /** Test seam: run one adoption-poll pass immediately. */ pollOnce(): void; } export declare function createJobRegistry(opts: JobRegistryOptions): JobRegistryHandle; //# sourceMappingURL=jobs.d.ts.map