/** * pi-nightshift job registry - pure logic, no pi imports (AGENTS.md 硬约束 5). * Behavior contract ported from dsh tool-jobs; one Map is the whole registry (D1). */ import { spawn, type ChildProcess } from "node:child_process"; import { StringDecoder } from "node:string_decoder"; export type JobStatus = "running" | "stopping" | "completed" | "killed" | "failed"; /** How to reach a shell; index.ts fills this from pi's getShellConfig(). */ export interface ShellSpec { shell: string; args: string[]; /** WSL-style `bash -s`: the command travels via stdin instead of argv. */ stdinTransport: boolean; cwd: string; env: NodeJS.ProcessEnv; } export interface Job { id: string; kind: string; label: string; status: JobStatus; detail?: string; startedAt: number; finishedAt?: number; /** Phase 2: set when the model already learned the outcome (kill/terminal read). */ reported: boolean; /** Pending waitFor callers; >0 at settle means a wait returns the terminal state (dsh). */ waiters: number; output: string; /** Char offset already consumed by reads. */ cursor: number; child: ChildProcess | undefined; killRequested: boolean; /** Resolves when the job reaches a terminal status; never rejects. */ settled: Promise; } /** Model-safe shape: bookkeeping fields (child/cursor/reported) stripped. */ export interface JobSnapshot { id: string; kind: string; label: string; status: JobStatus; detail?: string; startedAt: number; finishedAt?: number; } export function snapshot(job: Job): JobSnapshot { return { id: job.id, kind: job.kind, label: job.label, status: job.status, detail: job.detail, startedAt: job.startedAt, finishedAt: job.finishedAt, }; } /** `[status: running]` / `[status: completed, exit code: 0]` */ export function statusLine(job: Pick): string { return job.detail === undefined ? `[status: ${job.status}]` : `[status: ${job.status}, ${job.detail}]`; } const TERMINAL: readonly JobStatus[] = ["completed", "killed", "failed"]; export function isTerminal(job: Pick): boolean { return TERMINAL.includes(job.status); } /** dsh validateJobId: the non-empty constraint the parameter schema cannot express. */ function validateJobId(id: string): string { if (id.length === 0) { throw new Error("invalid job_id: expected a non-empty string"); } return id; } /** * Kill a process and all its children. Mirrors pi's built-in bash backend * (dist/utils/shell.js killProcessTree - taskkill /T on Windows, process-group * SIGKILL on Unix). Copied because pi does not export it at package root. */ function killProcessTree(pid: number | undefined): void { if (pid === undefined) return; if (process.platform === "win32") { try { spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore", detached: true, windowsHide: true, }); } catch { // already dead } } else { try { process.kill(-pid, "SIGKILL"); } catch { try { process.kill(pid, "SIGKILL"); } catch { // already dead } } } } export class JobRegistry { private jobs = new Map(); private next = 1; /** Phase 2 seam: called synchronously when a job reaches a terminal status. */ onSettle: ((job: Job) => void) | undefined; start(command: string, label: string | undefined, shell: ShellSpec): Job { const id = `bash-${this.next++}`; let settle!: () => void; const job: Job = { id, kind: "bash", label: label ?? command.split("\n")[0]!.slice(0, 80), status: "running", startedAt: Date.now(), reported: false, waiters: 0, output: "", cursor: 0, child: undefined, killRequested: false, settled: new Promise((resolve) => { settle = resolve; }), }; const decoder = new StringDecoder(); const append = (chunk: Buffer): void => { job.output += decoder.write(chunk); // ponytail: output grows unbounded until Phase 3 adds a tail cap for stream jobs. }; const doSettle = (status: JobStatus, detail?: string): void => { if (isTerminal(job)) return; job.status = status; if (detail !== undefined) job.detail = detail; job.finishedAt = Date.now(); // dsh suppression: a pending wait is about to return the terminal state. if (job.waiters > 0) job.reported = true; settle(); this.onSettle?.(job); }; const child = spawn( shell.shell, shell.stdinTransport ? shell.args : [...shell.args, command], { cwd: shell.cwd, detached: process.platform !== "win32", env: shell.env, stdio: [shell.stdinTransport ? "pipe" : "ignore", "pipe", "pipe"], windowsHide: true, }, ); job.child = child; if (shell.stdinTransport) { child.stdin?.on("error", () => {}); child.stdin?.end(command); } child.stdout?.on("data", append); child.stderr?.on("data", append); child.on("error", (err) => doSettle("failed", err.message)); // "close" not "exit": fires after stdio is flushed, so output is complete. child.on("close", (code, signal) => { job.output += decoder.end(); if (job.killRequested) doSettle("killed"); else if (code === 0) doSettle("completed", "exit code: 0"); else if (code !== null) doSettle("failed", `exit code: ${code}`); else doSettle("failed", `signal: ${signal ?? "unknown"}`); }); this.jobs.set(id, job); return job; } get(id: string): Job { validateJobId(id); const job = this.jobs.get(id); if (job === undefined) throw new Error(`unknown job ${id}`); return job; } list(): JobSnapshot[] { return [...this.jobs.values()].map(snapshot); } /** Consuming delta read: output since the previous read (dsh read semantics). */ read(id: string): { text: string; job: JobSnapshot } { const job = this.get(id); const text = job.output.slice(job.cursor); job.cursor = job.output.length; // dsh suppression point: a terminal read means the model knows the outcome. if (isTerminal(job)) job.reported = true; return { text, job: snapshot(job) }; } /** Snapshot without consuming output (job_kill uses this). */ peek(id: string): JobSnapshot { return snapshot(this.get(id)); } /** Resolve at terminal status or after `timeoutMs`, whichever first. Never rejects. */ waitFor(id: string, timeoutMs: number): Promise { const job = this.get(id); if (isTerminal(job)) return Promise.resolve(); job.waiters += 1; return new Promise((resolve) => { const done = (): void => { job.waiters -= 1; resolve(); }; const timer = setTimeout(done, timeoutMs); job.settled.then(() => { clearTimeout(timer); done(); }); }); } /** Request cancellation. A terminal job reports already-finished, output untouched. */ kill(id: string, reason?: string): { outcome: "cancellation-requested" | "already-finished"; job: JobSnapshot } { const job = this.get(id); if (isTerminal(job)) { job.reported = true; // dsh: killing a finished job suppresses its notice too return { outcome: "already-finished", job: snapshot(job) }; } job.reported = true; // dsh suppression point: the model asked to kill it, it knows job.killRequested = true; job.status = "stopping"; if (reason !== undefined) job.detail = `kill requested: ${reason}`; killProcessTree(job.child?.pid); return { outcome: "cancellation-requested", job: snapshot(job) }; } /** session_shutdown: idempotently kill every live child (D6: jobs die with pi). */ shutdownAll(): void { for (const job of this.jobs.values()) { if (isTerminal(job) || job.status === "stopping") continue; job.killRequested = true; job.status = "stopping"; killProcessTree(job.child?.pid); } } }