ci / check (push) Waiting to run
remote.scheduled (lembas service scheduled on|off, lembas remote scheduled on|off) said as limits.scheduled; a scheduled session runs in the schedule's mode (Edit when none, Plan refused, lowered to max_mode), reports ok/silent/failed/cancelled and the final reply, waits the schedule's wait on approvals and offers no question, plan card or show. _lembas/monitor judges the watch command by the mode (ask is refused) and hashes the output as it streams. Conformance areas scheduled, silent and monitor run here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
239 lines
11 KiB
TypeScript
239 lines
11 KiB
TypeScript
// Scheduled work on a linked computer (harness acp.md, spec 3.5.0): what this device makes of a
|
|
// scheduled turn and of a monitor's watch command. The server decides when a schedule runs; the
|
|
// device decides whether (Accept scheduled work, `remote.scheduled`) and how far (the schedule's
|
|
// mode, never wider than Edit unless it says Auto, lowered to remote.max_mode). The pure rules are
|
|
// here, run by the conformance areas `scheduled`, `silent` and `monitor`; agent.ts wires them in.
|
|
import { spawn } from "node:child_process"
|
|
import { createHash } from "node:crypto"
|
|
import { join } from "node:path"
|
|
import { loadConfig } from "../config/load.ts"
|
|
import type { Mode, PermissionConfig } from "../config/schema.ts"
|
|
import { LOOP } from "../harness.ts"
|
|
import { DEFAULT_RULES, evaluate, toRules, type Rule } from "../permission/evaluate.ts"
|
|
import { BUILTIN_HARDLINE, hardlineRules, type HardlineRule } from "../permission/hardline.ts"
|
|
import { fillPrompt } from "../prompt/fill.ts"
|
|
import { findProject, trustOf } from "../project/root.ts"
|
|
import type { Message } from "../provider/types.ts"
|
|
|
|
export const SCHEDULED = LOOP.scheduled
|
|
|
|
const RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
|
|
/** A mode lowered to `max` (agent.ts clampMode, kept here so neither imports the other). */
|
|
const clampMode = (wanted: Mode, max: Mode): Mode => (RANK[wanted] < RANK[max] ? max : wanted)
|
|
|
|
/** Why a scheduled request is refused: `data.reason` of the -32602. */
|
|
export type Refusal = "scheduled_off" | "plan" | "folder" | "permission"
|
|
|
|
/** What a scheduled request says (`_meta.lembas.scheduled`, or `_lembas/monitor`'s params). */
|
|
export interface ScheduledMeta {
|
|
schedule_id?: string
|
|
run_id?: string
|
|
title?: string
|
|
mode?: unknown
|
|
approval_wait_s?: unknown
|
|
}
|
|
|
|
/** A scheduled turn's settings, once the device has judged it. */
|
|
export interface ScheduledTurn {
|
|
scheduleId: string
|
|
runId: string
|
|
title: string
|
|
mode: Mode
|
|
waitMs: number
|
|
}
|
|
|
|
/** The schedule's mode and approval wait as this device runs them, or why it refuses
|
|
* (conformance/scheduled.json). `accept`: remote.scheduled with remote work on. */
|
|
export function scheduledMode(o: { accept: boolean; mode?: unknown; max_mode: Mode; approval_wait_s?: unknown }): { mode: Mode; wait_s: number } | { refused: Refusal } {
|
|
if (!o.accept) return { refused: "scheduled_off" }
|
|
if (o.mode === "plan") return { refused: "plan" }
|
|
// An unknown word is the default, never anything wider (`unrestricted` is not read as auto here).
|
|
const chosen: Mode = (SCHEDULED.modes as string[]).includes(o.mode as string) ? (o.mode as Mode) : (SCHEDULED.default_mode as Mode)
|
|
return { mode: clampMode(chosen, o.max_mode), wait_s: approvalWait(o.approval_wait_s) }
|
|
}
|
|
|
|
/** The approval wait in whole seconds, clamped (loop.json scheduled.approval_wait_seconds). */
|
|
export function approvalWait(v: unknown): number {
|
|
const W = SCHEDULED.approval_wait_seconds
|
|
const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : W.default
|
|
return Math.max(W.min, Math.min(W.max, n))
|
|
}
|
|
|
|
/** The fixed text of a refusal. */
|
|
export function refusalText(reason: "scheduled_off" | "plan"): string {
|
|
return reason === "plan" ? SCHEDULED.texts.plan : SCHEDULED.texts.off
|
|
}
|
|
|
|
/** The feedback a scheduled approval nobody answered gives the model. */
|
|
export function deniedText(waitMs: number): string {
|
|
return fillPrompt(SCHEDULED.texts.denied, { minutes: String(Math.max(1, Math.round(waitMs / 60_000))) })
|
|
}
|
|
|
|
// --- The sentinel --------------------------------------------------------------------------
|
|
|
|
const marks = (line: string) => line.trim().replace(/\.+$/, "").trim().toUpperCase() === SCHEDULED.sentinel
|
|
|
|
/** Whether a reply is silent: the sentinel on its first or last non-empty line, any case, every
|
|
* trailing full stop forgiven (conformance/silent.json; the server's `notify.is_silent`). */
|
|
export function isSilent(text: string): boolean {
|
|
const lines = (text ?? "")
|
|
.trim()
|
|
.split(/\r?\n/)
|
|
.map((l) => l.trim())
|
|
.filter(Boolean)
|
|
return lines.length > 0 && (marks(lines[0]!) || marks(lines[lines.length - 1]!))
|
|
}
|
|
|
|
/** The reply without any sentinel line, trimmed: what a preview shows. */
|
|
export function stripSentinel(text: string): string {
|
|
return (text ?? "")
|
|
.trim()
|
|
.split(/\r?\n/)
|
|
.filter((l) => !marks(l))
|
|
.join("\n")
|
|
.trim()
|
|
}
|
|
|
|
/** The text of the last assistant message: a turn's final reply. */
|
|
export function finalReply(messages: Message[]): string {
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
const m = messages[i]!
|
|
if (m.role === "user") return ""
|
|
if (m.role !== "assistant") continue
|
|
const text = m.parts.map((p) => (p.type === "text" ? p.text : "")).join("")
|
|
if (text.trim()) return text
|
|
}
|
|
return ""
|
|
}
|
|
|
|
/** The run's outcome, for `session/prompt`'s `_meta.lembas.scheduled`. */
|
|
export function runOutcome(o: { runId: string; reason: string; text: string; error?: string }) {
|
|
let status: "ok" | "silent" | "failed" | "cancelled"
|
|
let error: string | null = null
|
|
if (o.reason === "cancelled") status = "cancelled"
|
|
else if (o.reason === "error") {
|
|
status = "failed"
|
|
error = o.error || "the turn failed"
|
|
} else if (!o.text.trim()) {
|
|
status = "failed"
|
|
error = SCHEDULED.texts.no_reply
|
|
} else status = isSilent(o.text) ? "silent" : "ok"
|
|
return { run_id: o.runId, status, text: o.text, error }
|
|
}
|
|
|
|
// --- The watch command ---------------------------------------------------------------------
|
|
|
|
/** `sha256:` and the hex SHA-256 of the output's bytes as read, a newline, and the exit status:
|
|
* the status after the bytes, so a stream is hashed as it comes (conformance/monitor.json). */
|
|
export function monitorHash(exit: number, output: Uint8Array | string): string {
|
|
const h = createHash("sha256")
|
|
h.update(typeof output === "string" ? Buffer.from(output, "utf8") : output)
|
|
h.update(`\n${exit}`)
|
|
return `sha256:${h.digest("hex")}`
|
|
}
|
|
|
|
/** The permission context a `bash` call in `cwd` would be judged in: the hardline, the default
|
|
* rules, the global rules and — in a trusted project — the project's (as app.ts builds them). */
|
|
export function permissionFor(cwd: string): { rules: Rule[]; hardline: HardlineRule[]; root: string; planDir: string; projectDir: string } {
|
|
const project = findProject(cwd)
|
|
const trusted = trustOf(project.root) === "trusted"
|
|
const loaded = loadConfig({ projectConfigDir: project.dir, trusted })
|
|
return {
|
|
rules: [...toRules(DEFAULT_RULES, "default"), ...loaded.permissions.flatMap((p, i) => toRules(p, loaded.permissionSources[i] ?? "global"))],
|
|
hardline: hardlineRules({ extra: loaded.config.hardline_extra, disable: loaded.config.hardline_disable }),
|
|
root: project.root,
|
|
planDir: join(project.dir, "plans"),
|
|
projectDir: project.dir,
|
|
}
|
|
}
|
|
|
|
/** Whether a watch command may run in `mode`: only where the mode allows it without asking
|
|
* (conformance/monitor.json `judge`). */
|
|
export function judgeMonitor(line: string, mode: Mode, ctx: { rules: Rule[]; hardline: HardlineRule[]; root: string; cwd?: string; planDir?: string; projectDir?: string }): { run: true } | { refused: "asks" | "denied"; text: string } {
|
|
const d = evaluate({ permission: "bash", class: "execute", patterns: [line], command: line, paths: [ctx.cwd ?? ctx.root] }, { mode, rules: ctx.rules, hardline: ctx.hardline, root: ctx.root, planDir: ctx.planDir, projectDir: ctx.projectDir })
|
|
if (d.action === "allow") return { run: true }
|
|
const reason = d.reason || (d.action === "ask" ? "it is not allowed without asking" : "refused")
|
|
if (d.action === "ask") return { refused: "asks", text: fillPrompt(SCHEDULED.texts.monitor_asks, { mode, reason }) }
|
|
return { refused: "denied", text: fillPrompt(SCHEDULED.texts.monitor_denied, { reason }) }
|
|
}
|
|
|
|
/** The conformance runner's judge: the default rules and the case's own as global rules. */
|
|
export function judgeCase(line: string, mode: Mode, extra?: PermissionConfig) {
|
|
const rules = [...toRules(DEFAULT_RULES, "default"), ...toRules(extra ?? {}, "global")]
|
|
return judgeMonitor(line, mode, { rules, hardline: BUILTIN_HARDLINE, root: "/project", planDir: "/project/.agent/plans", projectDir: "/project/.agent" })
|
|
}
|
|
|
|
export interface MonitorResult {
|
|
hash: string | null
|
|
exit: number | null
|
|
timed_out: boolean
|
|
excerpt: string
|
|
truncated: boolean
|
|
bytes: number
|
|
duration_ms: number
|
|
}
|
|
|
|
/** The watch command's timeout in seconds, defaulted and clamped. */
|
|
export function monitorTimeout(v: unknown): number {
|
|
const T = SCHEDULED.monitor.timeout_seconds
|
|
const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : T.default
|
|
return Math.max(T.min, Math.min(T.max, n))
|
|
}
|
|
|
|
/** Run a watch command as `bash -c` in `cwd`, in its own process group, with no input; stop the
|
|
* group at the timeout. Everything streamed is hashed; the first output_max_bytes are kept. */
|
|
export function runMonitor(command: string, cwd: string, timeoutS: number, env: Record<string, string | undefined> = process.env): Promise<MonitorResult> {
|
|
const M = SCHEDULED.monitor
|
|
const started = Date.now()
|
|
return new Promise((resolve) => {
|
|
const child = spawn("bash", ["-c", `exec 2>&1\n${command}`], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"], env })
|
|
const hash = createHash("sha256")
|
|
const kept: Buffer[] = []
|
|
let keptBytes = 0
|
|
let bytes = 0
|
|
let timedOut = false
|
|
const onData = (d: Buffer) => {
|
|
bytes += d.length
|
|
hash.update(d)
|
|
if (keptBytes < M.output_max_bytes) {
|
|
const piece = d.subarray(0, M.output_max_bytes - keptBytes)
|
|
kept.push(piece)
|
|
keptBytes += piece.length
|
|
}
|
|
}
|
|
child.stdout!.on("data", onData)
|
|
child.stderr!.on("data", onData)
|
|
const killGroup = (sig: NodeJS.Signals) => {
|
|
try {
|
|
process.kill(-child.pid!, sig)
|
|
} catch {}
|
|
}
|
|
const timer = setTimeout(() => {
|
|
timedOut = true
|
|
killGroup("SIGTERM")
|
|
setTimeout(() => killGroup("SIGKILL"), 2000).unref()
|
|
}, timeoutS * 1000)
|
|
let done = false
|
|
const finish = (code: number | null) => {
|
|
if (done) return
|
|
done = true
|
|
clearTimeout(timer)
|
|
const text = Buffer.concat(kept).toString("utf8")
|
|
const excerpt = text.slice(0, M.excerpt_chars)
|
|
const exit = timedOut || child.signalCode ? null : code
|
|
if (exit !== null) hash.update(`\n${exit}`)
|
|
resolve({
|
|
hash: exit === null ? null : `sha256:${hash.digest("hex")}`,
|
|
exit,
|
|
timed_out: timedOut,
|
|
excerpt,
|
|
truncated: bytes > Buffer.byteLength(excerpt, "utf8"),
|
|
bytes,
|
|
duration_ms: Date.now() - started,
|
|
})
|
|
}
|
|
child.on("close", (code) => finish(code))
|
|
child.on("error", () => finish(127))
|
|
})
|
|
}
|