fix(daemon): PL-004 Phase C R1 — POC contract parity for watchdog policies + active-wake throttle

Repairs guard BLOCK on commit 48c0267:

1. scan_interval_seconds vs active_wake_interval_seconds (blocker 1):
   - Scheduler isDue uses scan_interval_seconds when set, falling back
     to interval_seconds. Mirrors POC engine.mjs:38-46.
   - Policy engine enforces active_wake_interval_seconds as a re-delivery
     throttle: when state.actionable was already true AND wake-window
     not elapsed since last_fire_at, returns quiet skip with reason
     active_wake_not_due (does NOT call delivery, preserves last_fire_at).
     Mirrors POC engine.mjs:49-64 and :243-263.
   - New columns on watchdog_jobs: actionable INTEGER NOT NULL DEFAULT 0,
     last_actionable_at TEXT. Engine sets these on every meaningful
     evaluation; clears on skip; preserves last_actionable_at across
     continued-actionable windows. Migration extended in-place since
     031 has not yet been promoted past Phase B HEAD.
   - Ported POC active-wake regression (tests/watchdog-active-wake-interval.test.sh)
     into daemon test as a 6-tick state-machine regression.

2. edge-artifact-required POC contract (blocker 2):
   - Spec uses context.source / context.target (singular pool specs)
     not source_pools / target_pools. Each may have path or paths.
   - Edge-satisfaction is body-match: a downstream artifact satisfies
     a source iff target.raw INCLUDES the source key. NOT a frontmatter
     field match. Mirrors POC edge-artifact-required.mjs:11-12.
   - Target scan overrides include_statuses to [] (POC override) so any
     downstream state counts as 'exists'.
   - Delivery target is job.target.session (top-level), not from context.
   - Label key is context.edge_label (POC parity).
   - Daemon test ports the POC pilot-a body-reference scenario.

3. Artifact-pool scanner POC parity (blocker 3):
   - Default ignores README.md and .DS_Store always applied.
   - Configured pool.ignore_names extends default ignores.
   - pool.recursive=true descends into subdirectories.
   - Malformed-frontmatter excluded unless pool.include_malformed_frontmatter.
   - Raw content preserved on every artifact (used by edge-artifact-required
     body-match).
   - ENOENT-tolerant pool path.
   - artifact-pool-ready and artifact-pool-helpers tests cover all 5 POC
     scanner behaviors.

Engine state-machine alignment to POC:
   - PolicyJob shape uses {target: {session}, message?, context}. Engine
     parses spec_yaml top-level target + message blocks and falls back to
     {session: registered targetSession} when spec lacks target.
   - Policies all read job.target.session for delivery target and
     job.message OR context.message for periodic-reminder.
   - PolicyEvaluation send action returns target as object {session: ...}
     to match POC contract (engine extracts session string for delivery).
   - QUIET_SKIP_REASONS set (not_due, no_actionable_artifacts,
     no_missing_edge_artifacts, active_wake_not_due) suppresses both
     history record AND event emit. Loud skips still record + emit.
     Mirrors POC shouldAppendHistory (engine.mjs:99-112).

Verification:
   - tsc clean for daemon and cli.
   - Per-package vitest baseline under explicit OPENRIG_HOME +
     OPENRIG_DB=:memory: temp env:
     - packages/daemon: 2485 / 2485 pass (was 2471; +14 new watchdog
       tests covering POC contract + active-wake state machine).
     - packages/cli: 7 / 7 watchdog tests pass.

Boundaries preserved:
   - Worktree-only on phase-c-coordination-primitive-system branch;
     main untouched.
   - No-touch surfaces (Phase A stream/queue/hot-potato, Phase B
     project/classifier/view, Lifecycle v2, migrations 001-030, workflow
     runtime, progress-rail code, POC services/watchdog state) unchanged.
   - workflow-keepalive still rejected at register with
     policy_deferred_to_phase_d; not added as a product policy module.
   - No fleet sweep, no protected-host mutation, no external spend, no
     destructive lifecycle, no VM-boundary actions.

Docs updated in architecture.md and cli-reference.md to reflect the
quiet-skip parity + scan/wake cadence semantics. codemap entries from
48c0267 still describe the same files.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-03 00:33:37 -07:00
1 parent 48c0267615
commit dd9820eb2d
15 files changed
+1012 -393

No files matched your search

+1 -1
View File
@@ -326,7 +326,7 @@ Migration boundary:
- `022_node_codex_config_profile.ts` adds Codex config-profile field on nodes.
- `023_stream_items.ts` through `027_outbox_entries.ts` add the PL-004 Phase A coordination tables (see § Coordination Layer below).
- `028_project_classifications.ts` through `030_views_custom.ts` add the PL-004 Phase B classifier + view tables.
- `031_watchdog_jobs.ts` and `032_watchdog_history.ts` add the PL-004 Phase C watchdog supervision-tree tables. `watchdog_jobs` is the canonical scheduler state (policy, target_session, intervals, lifecycle); `watchdog_history` is the append-only audit of meaningful evaluations (`sent`/`skipped`/`terminal`). Pure `not_due` polls are not recorded. Three v1 policies: `periodic-reminder`, `artifact-pool-ready`, `edge-artifact-required`. The fourth POC policy `workflow-keepalive` is intentionally rejected at registration with `policy_deferred_to_phase_d` and ships in Phase D paired with workflow_instances.
- `031_watchdog_jobs.ts` and `032_watchdog_history.ts` add the PL-004 Phase C watchdog supervision-tree tables. `watchdog_jobs` is the canonical scheduler state (policy, target_session, scan + active-wake intervals, actionable + last_actionable_at active-wake state, lifecycle); `watchdog_history` is the append-only audit of meaningful evaluations only (loud `sent`/`skipped`/`terminal`). Quiet skip reasons (`not_due`, `no_actionable_artifacts`, `no_missing_edge_artifacts`, `active_wake_not_due`) are NOT recorded and do NOT emit `watchdog.*` events — POC parity so agents are not woken about scheduler polls. Scan cadence (`scan_interval_seconds`) is enforced by the scheduler; re-delivery cadence (`active_wake_interval_seconds`) is enforced by the policy engine and only suppresses repeated wakes during a continuously-actionable window. Three v1 policies: `periodic-reminder`, `artifact-pool-ready`, `edge-artifact-required`. The fourth POC policy `workflow-keepalive` is intentionally rejected at registration with `policy_deferred_to_phase_d` and ships in Phase D paired with workflow_instances.
### Coordination tables (PL-004 Phase A)
+1 -1
View File
@@ -712,7 +712,7 @@ Closure-reason semantics:
- `status <job_id>` — show job + recent evaluation history (last 20 entries).
- `stop <job_id> [--reason <text>]` — operator stop; scheduler skips the job thereafter.
History records only meaningful evaluations: `sent` (delivery executed), `skipped` (policy returned skip with a reason such as `no_actionable_artifacts`), or `terminal` (policy declared the job done). Pure `not_due` polls are not recorded.
History records only loud evaluations: `sent` (delivery executed) or `terminal` (policy declared the job done). Quiet skip reasons (`not_due`, `no_actionable_artifacts`, `no_missing_edge_artifacts`, `active_wake_not_due`) are NOT recorded — POC parity so agents are not woken about scheduler polls. Loud `skipped` rows are recorded only if a policy returns a non-quiet reason.
## Commands Not Present
@@ -15,8 +15,20 @@ import type { Migration } from "../migrate.js";
* stopped - operator stopped the job; scheduler skips it
* terminal - policy declared the job complete (e.g., workflow finished)
*
* Schema details mirror the POC declarative spec (target_session,
* interval_seconds, optional pool-ready-specific scan/active intervals).
* Cadence columns mirror POC engine semantics:
* - interval_seconds is the legacy / fallback cadence.
* - scan_interval_seconds overrides the scheduler isDue cadence
* (how often to run the policy at all).
* - active_wake_interval_seconds throttles re-delivery: when the
* pool stays continuously actionable, fire only every Nth scan.
*
* Active-wake state (R1 fix; POC parity for active-wake throttle):
* - actionable: 1 iff the most recent scan returned action=send.
* Used to detect newly-actionable transitions (no throttle on
* transition; throttle on continued actionable state).
* - last_actionable_at: first attempt time during the current
* actionable window. Cleared when the policy returns skip.
*
* spec_yaml preserves the original operator-supplied YAML for audit.
*/
export const watchdogJobsSchema: Migration = {
@@ -32,6 +44,8 @@ export const watchdogJobsSchema: Migration = {
scan_interval_seconds INTEGER,
last_evaluation_at TEXT,
last_fire_at TEXT,
actionable INTEGER NOT NULL DEFAULT 0,
last_actionable_at TEXT,
state TEXT NOT NULL DEFAULT 'active',
registered_by_session TEXT NOT NULL,
registered_at TEXT NOT NULL,
@@ -1,136 +1,259 @@
// PL-004 Phase C: shared artifact-pool helpers (TypeScript port of POC
// `lib/policies/artifact-pool.js`).
// PL-004 Phase C R1: shared artifact-pool helpers (TypeScript port of
// POC `lib/policies/artifact-pool.js`).
//
// Used by artifact-pool-ready and edge-artifact-required policies. Pure
// filesystem scanner; no event-bus, no DB, no Hono. Reverse-engineers
// the POC contract for include_statuses + frontmatter extraction.
// R1 fix (guard blocker 3): full POC parity for the scanner. The
// artifact-pool-ready and edge-artifact-required policies depend on:
// - Default ignores: README.md and .DS_Store always excluded.
// - Configured ignore_names: per-pool extra exclusions.
// - Recursive scan when pool.recursive=true.
// - Malformed-frontmatter exclusion unless pool.include_malformed_frontmatter.
// - Raw content preserved on every artifact (used by edge-artifact-required
// for body-reference target satisfaction).
// - Missing pool path returns empty (ENOENT-tolerant).
//
// Pure filesystem scanner; no event-bus, no DB, no Hono.
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, basename } from "node:path";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { basename, join } from "node:path";
const DEFAULT_EXTENSIONS = [".md"];
const DEFAULT_IGNORE_NAMES = ["README.md", ".DS_Store"];
export interface ArtifactPoolSpec {
/** Absolute path to the pool directory (or `paths` array variant). */
/** Absolute path to the pool directory. POC contract: one path per pool. */
path?: string;
/** Convenience array form; expanded to one pool per path. */
paths?: string[];
/** File extensions to include (default: ['.md']). */
extensions?: string[];
/** Filter on frontmatter `status:` field (default: include all). */
/** Filter on frontmatter `status:` field. Empty/absent = include all. */
include_statuses?: string[];
/** Frontmatter field used for keying artifacts (default: 'entry'). */
key_field?: string;
/** Per-pool extra ignore names (added to defaults). */
ignore_names?: string[];
/** When true, descend into subdirectories. Default false. */
recursive?: boolean;
/**
* When true, artifacts whose frontmatter cannot be parsed are still
* included with empty frontmatter. Default false: malformed frontmatter
* causes the artifact to be excluded (matches POC behavior so that
* agents do not get woken about half-written drafts).
*/
include_malformed_frontmatter?: boolean;
}
export interface ScannedArtifact {
/** Absolute path to the artifact file. */
path: string;
/** The pool path that produced this artifact. */
pool_path: string;
/** Full file content (used by edge-artifact-required body-match). */
raw: string;
/** Parsed YAML frontmatter (top-level keys only). */
frontmatter: Record<string, string>;
/** Parse error message when include_malformed_frontmatter=true; else null. */
frontmatter_parse_error: string | null;
/** Convenience accessor for frontmatter.status; null when absent. */
status: string | null;
}
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/;
const FRONTMATTER_OPEN = "---\n";
/**
* Parse top-level YAML frontmatter from a markdown-style document.
* Only extracts `key: value` pairs (no nested structures, no arrays).
* Mirrors POC's lightweight extraction in lib/policies/artifact-pool.js.
* Returns { raw, frontmatter, parseError } so callers can decide whether
* to include malformed artifacts.
*
* Detection of malformed frontmatter mirrors POC behavior: lines that
* fail the `key: value` shape after the opener are treated as parse
* errors (rather than silently dropped) so include_malformed_frontmatter
* gating works as POC tests expect.
*/
function parseFrontmatter(content: string): Record<string, string> {
const match = FRONTMATTER_RE.exec(content);
if (!match) return {};
const block = match[1] ?? "";
const fm: Record<string, string> = {};
for (const line of block.split("\n")) {
const idx = line.indexOf(":");
if (idx === -1) continue;
const key = line.slice(0, idx).trim();
let value = line.slice(idx + 1).trim();
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
if (key) fm[key] = value;
function readFrontmatter(filePath: string): {
raw: string;
frontmatter: Record<string, string>;
frontmatter_parse_error: string | null;
} {
const raw = readFileSync(filePath, "utf-8");
if (!raw.startsWith(FRONTMATTER_OPEN)) {
return { raw, frontmatter: {}, frontmatter_parse_error: null };
}
return fm;
const endIdx = raw.indexOf("\n---\n", FRONTMATTER_OPEN.length);
if (endIdx === -1) {
return { raw, frontmatter: {}, frontmatter_parse_error: null };
}
const block = raw.slice(FRONTMATTER_OPEN.length, endIdx);
const fm: Record<string, string> = {};
let parseError: string | null = null;
for (const line of block.split("\n")) {
if (line.trim().length === 0) continue;
const colonIdx = line.indexOf(":");
if (colonIdx === -1) {
parseError = `frontmatter line missing ':' (${line.slice(0, 40)})`;
break;
}
const key = line.slice(0, colonIdx).trim();
const valueRaw = line.slice(colonIdx + 1);
let value = valueRaw.trim();
// Detect malformed values like 'broken: value: still broken' where the
// value side itself contains another `:` outside quotes. POC treats
// these as parse errors so half-written drafts do not slip through.
const stripped = value.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
if (
stripped !== value &&
stripped.length === value.length - 2
) {
// Was quoted; treat as a clean string regardless of inner colons.
value = stripped;
} else if (value.includes(":")) {
parseError = `frontmatter value contains unescaped ':' for key '${key}'`;
break;
}
if (!key) {
parseError = "frontmatter line missing key";
break;
}
fm[key] = value;
}
return {
raw,
frontmatter: parseError ? {} : fm,
frontmatter_parse_error: parseError,
};
}
/**
* Scan a single pool directory for artifacts matching extensions +
* include_statuses. Returns ScannedArtifact[] with parsed frontmatter.
*/
function scanPool(spec: ArtifactPoolSpec & { path: string }): ScannedArtifact[] {
if (!existsSync(spec.path)) return [];
let entries: string[];
function listFiles(rootDir: string, recursive: boolean): string[] {
let entries: import("node:fs").Dirent[];
try {
entries = readdirSync(spec.path);
} catch {
return [];
entries = readdirSync(rootDir, { withFileTypes: true });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
throw err;
}
const exts = spec.extensions ?? [".md"];
const includeStatuses = spec.include_statuses;
const out: ScannedArtifact[] = [];
const out: string[] = [];
for (const entry of entries) {
if (!exts.some((e) => entry.endsWith(e))) continue;
const fullPath = join(spec.path, entry);
let st;
try {
st = statSync(fullPath);
} catch {
const full = join(rootDir, entry.name);
if (entry.isDirectory()) {
if (recursive) out.push(...listFiles(full, recursive));
continue;
}
if (!st.isFile()) continue;
let content: string;
try {
content = readFileSync(fullPath, "utf-8");
} catch {
continue;
}
const fm = parseFrontmatter(content);
if (includeStatuses && includeStatuses.length > 0) {
if (!includeStatuses.includes(fm.status ?? "")) continue;
}
out.push({ path: fullPath, frontmatter: fm });
if (entry.isFile()) out.push(full);
}
return out;
return out.sort((a, b) => a.localeCompare(b));
}
function shouldIncludeFile(filePath: string, pool: ArtifactPoolSpec): boolean {
const name = basename(filePath);
const ignoreNames = new Set([
...DEFAULT_IGNORE_NAMES,
...(Array.isArray(pool.ignore_names) ? pool.ignore_names : []),
]);
if (ignoreNames.has(name)) return false;
const exts = Array.isArray(pool.extensions) ? pool.extensions : DEFAULT_EXTENSIONS;
return exts.some((e) => name.endsWith(e));
}
function statusAllowed(status: string | null, pool: ArtifactPoolSpec): boolean {
const include = pool.include_statuses;
if (!Array.isArray(include) || include.length === 0) return true;
return status !== null && include.includes(status);
}
/**
* Scan one or more artifact pools per spec(s). Mirrors POC
* `scanArtifactPools(pools)`. Returns the flat union of matches across
* all pools. Missing/unreadable pools yield empty contributions.
* all pools, sorted by absolute path. Missing pools yield empty.
*
* Throws if `pools` is undefined / empty / shapeless — POC contract:
* the policy spec MUST declare at least one pool.
*/
export async function scanArtifactPools(
pools: ArtifactPoolSpec | ArtifactPoolSpec[] | undefined,
): Promise<ScannedArtifact[]> {
const expanded = expandPools(pools);
if (expanded.length === 0) {
throw new Error("artifact pool policy: context pool list is required");
}
const out: ScannedArtifact[] = [];
for (const pool of expanded) {
if (!pool.path) {
throw new Error("artifact pool policy: every pool requires a path");
}
const files = listFiles(pool.path, Boolean(pool.recursive));
for (const filePath of files) {
if (!shouldIncludeFile(filePath, pool)) continue;
let st;
try {
st = statSync(filePath);
} catch {
continue;
}
if (!st.isFile()) continue;
let parsed;
try {
parsed = readFrontmatter(filePath);
} catch {
continue;
}
if (parsed.frontmatter_parse_error && !pool.include_malformed_frontmatter) {
continue;
}
const status = parsed.frontmatter.status ?? null;
if (!statusAllowed(status, pool)) continue;
out.push({
path: filePath,
pool_path: pool.path,
raw: parsed.raw,
frontmatter: parsed.frontmatter,
frontmatter_parse_error: parsed.frontmatter_parse_error,
status,
});
}
}
return out.sort((a, b) => a.path.localeCompare(b.path));
}
/**
* Expand the convenience input shape (single object, array of objects,
* or `paths: [...]` shorthand) into a flat array of single-path pools.
*/
function expandPools(
pools: ArtifactPoolSpec | ArtifactPoolSpec[] | undefined,
): ArtifactPoolSpec[] {
if (!pools) return [];
const list = Array.isArray(pools) ? pools : [pools];
const out: ScannedArtifact[] = [];
const out: ArtifactPoolSpec[] = [];
for (const spec of list) {
if (spec.path) {
out.push(...scanPool({ ...spec, path: spec.path }));
out.push(spec);
} else if (Array.isArray(spec.paths)) {
for (const p of spec.paths) out.push(...scanPool({ ...spec, path: p }));
for (const p of spec.paths) out.push({ ...spec, path: p, paths: undefined });
}
}
return out;
}
/**
* Format an artifact list as bullet lines. Mirrors POC formatArtifactList.
* Format an artifact list as bullet lines. Mirrors POC formatArtifactList:
* `- /full/absolute/path`. The POC favors the full path so receivers can
* cd / open / cat the artifact directly without further lookup.
*/
export function formatArtifactList(artifacts: ScannedArtifact[], maxItems: number): string {
return artifacts
.slice(0, maxItems)
.map((a) => `- ${basename(a.path)} (${a.path})`)
.map((a) => `- ${a.path}`)
.join("\n");
}
/**
* Compute the source-key for an artifact (used by edge-artifact-required).
* Mirrors POC sourceKeyFor: prefer frontmatter[keyField], else basename
* sans .md extension.
* Compute the source-key for an artifact. Mirrors POC sourceKeyFor:
* prefer frontmatter[keyField], else basename sans .md extension.
*/
export function sourceKeyFor(artifact: ScannedArtifact, keyField: string): string {
const value = artifact.frontmatter[keyField];
if (value === undefined || value === null || value === "") {
return basename(artifact.path).replace(/\.md$/, "");
}
return value;
return String(value);
}
@@ -1,10 +1,10 @@
// PL-004 Phase C: artifact-pool-ready policy (TypeScript port of
// POC `lib/policies/artifact-pool-ready.mjs`, 29 lines).
// PL-004 Phase C R1: artifact-pool-ready policy (TypeScript port of
// POC `lib/policies/artifact-pool-ready.mjs`).
//
// Scans context.pools, returns:
// - skip { reason: "no_actionable_artifacts" } if pool empty
// - send { message: "Artifact pool ready: <label> has N actionable
// artifact(s)..." + bullet list capped at max_items (default 5) }
// POC contract: target.session is required (top-level `target:`).
// Scans context.pools (array). Empty → skip(no_actionable_artifacts).
// Otherwise sends a formatted message ending with the standard POC
// pool-ready instruction line.
import type { Policy, PolicyEvaluation, PolicyJob } from "./types.js";
import {
@@ -14,38 +14,41 @@ import {
} from "./artifact-pool-helpers.js";
interface ArtifactPoolReadyContext {
target?: { session?: string };
pools?: ArtifactPoolSpec | ArtifactPoolSpec[];
label?: string;
max_items?: number;
message_template?: string;
}
const POC_POOL_READY_TRAILER =
"Claim and process the next artifact, or terminally classify it with evidence. " +
"This is a pool-ready wake, not an approval gate, and it should not require orch to bridge the edge.";
export const artifactPoolReadyPolicy: Policy = {
name: "artifact-pool-ready",
async evaluate(job: PolicyJob): Promise<PolicyEvaluation> {
const context = job.context as ArtifactPoolReadyContext;
const targetSession = context.target?.session;
if (!targetSession) {
throw Object.assign(new Error("artifact-pool-ready requires context.target.session"), {
if (!job.target?.session) {
throw Object.assign(new Error("artifact-pool-ready: target.session is required"), {
code: "policy_spec_invalid",
policy: "artifact-pool-ready",
field: "context.target.session",
field: "target.session",
});
}
const context = job.context as ArtifactPoolReadyContext;
const artifacts = await scanArtifactPools(context.pools);
if (artifacts.length === 0) {
return { action: "skip", reason: "no_actionable_artifacts" };
}
const label = context.label ?? "artifact pool";
const maxItems = context.max_items ?? 5;
const maxItems = Number(context.max_items ?? 5);
const list = formatArtifactList(artifacts, maxItems);
const hiddenCount = Math.max(0, artifacts.length - maxItems);
const suffix = hiddenCount > 0 ? `\n- ... ${hiddenCount} more` : "";
const message =
context.message_template ??
`Artifact pool ready: ${label} has ${artifacts.length} actionable artifact(s).\n\n${list}`;
job.message ??
`Artifact pool ready: ${label} has ${artifacts.length} actionable artifact(s).\n${list}${suffix}\n\n${POC_POOL_READY_TRAILER}`;
return {
action: "send",
target: targetSession,
target: job.target,
message,
notes: { artifact_count: artifacts.length, label },
};
@@ -1,87 +1,112 @@
// PL-004 Phase C: edge-artifact-required policy (TypeScript port of
// POC `lib/policies/edge-artifact-required.mjs`, 70 lines).
// PL-004 Phase C R1: edge-artifact-required policy (TypeScript port of
// POC `lib/policies/edge-artifact-required.mjs`).
//
// Scans both source pools and target pools. For each source artifact,
// computes its source-key (frontmatter[key_field] OR basename-sans-md).
// If targets array contains no artifact with matching source-key,
// the source is "missing a downstream edge artifact." Emits a send
// only when one or more sources are missing edges; otherwise skip with
// reason "no_missing_edge_artifacts".
// R1 fix (guard blocker 2): preserves the POC contract.
// - Spec uses `context.source` (singular) and `context.target`
// (singular) pool specs. Each may have `path` or `paths`.
// - Edge-satisfaction: a downstream target artifact satisfies a
// source iff target.raw CONTAINS the source key. NOT a frontmatter
// match — body reference suffices.
// - Target scan overrides include_statuses to [] so any downstream
// state counts as "exists".
// - Delivery target is `job.target.session` (top-level).
// - Label key is `context.edge_label` (matches POC).
import type { Policy, PolicyEvaluation, PolicyJob } from "./types.js";
import {
type ArtifactPoolSpec,
type ScannedArtifact,
formatArtifactList,
scanArtifactPools,
sourceKeyFor,
} from "./artifact-pool-helpers.js";
interface EdgeArtifactRequiredContext {
target?: { session?: string };
source_pools?: ArtifactPoolSpec | ArtifactPoolSpec[];
target_pools?: ArtifactPoolSpec | ArtifactPoolSpec[];
source_key_field?: string;
target_key_field?: string;
label?: string;
source?: ArtifactPoolSpec & { paths?: string[] };
target?: ArtifactPoolSpec & { paths?: string[] };
edge_label?: string;
max_items?: number;
message_template?: string;
}
function targetContainsKey(
targets: ScannedArtifact[],
key: string,
targetKeyField: string,
): boolean {
for (const t of targets) {
if (sourceKeyFor(t, targetKeyField) === key) return true;
function targetContainsKey(targets: ScannedArtifact[], key: string): boolean {
return targets.some((t) => t.raw.includes(key));
}
function expandPoolSides(
spec: (ArtifactPoolSpec & { paths?: string[] }) | undefined,
): ArtifactPoolSpec[] {
if (!spec) return [];
if (Array.isArray(spec.paths)) {
return spec.paths.map((p) => ({ ...spec, path: p, paths: undefined }));
}
return false;
return [spec];
}
export const edgeArtifactRequiredPolicy: Policy = {
name: "edge-artifact-required",
async evaluate(job: PolicyJob): Promise<PolicyEvaluation> {
const context = job.context as EdgeArtifactRequiredContext;
const targetSession = context.target?.session;
if (!targetSession) {
throw Object.assign(new Error("edge-artifact-required requires context.target.session"), {
if (!job.target?.session) {
throw Object.assign(new Error("edge-artifact-required: target.session is required"), {
code: "policy_spec_invalid",
policy: "edge-artifact-required",
field: "context.target.session",
field: "target.session",
});
}
const sourceKeyField = context.source_key_field ?? "entry";
const targetKeyField = context.target_key_field ?? "entry";
const sources = await scanArtifactPools(context.source_pools);
// POC override: target scan ignores include_statuses (so we can detect
// any matching downstream artifact regardless of its lifecycle state).
const targetPoolsRaw = context.target_pools;
const targetPoolsList = targetPoolsRaw
? Array.isArray(targetPoolsRaw)
? targetPoolsRaw
: [targetPoolsRaw]
: [];
const targets = await scanArtifactPools(
targetPoolsList.map((p) => ({ ...p, include_statuses: [] })),
);
const context = job.context as EdgeArtifactRequiredContext;
const source = context.source;
const target = context.target;
if (!source?.path && !Array.isArray(source?.paths)) {
throw Object.assign(
new Error("edge-artifact-required: context.source.path is required"),
{
code: "policy_spec_invalid",
policy: "edge-artifact-required",
field: "context.source.path",
},
);
}
if (!target?.path && !Array.isArray(target?.paths)) {
throw Object.assign(
new Error("edge-artifact-required: context.target.path is required"),
{
code: "policy_spec_invalid",
policy: "edge-artifact-required",
field: "context.target.path",
},
);
}
const sourcePools = expandPoolSides(source);
const targetPools = expandPoolSides(target).map((p) => ({
...p,
include_statuses: [] as string[],
}));
const keyField = source?.key_field ?? "entry";
const sources = await scanArtifactPools(sourcePools);
const targets = await scanArtifactPools(targetPools);
const missing: ScannedArtifact[] = [];
for (const s of sources) {
const key = sourceKeyFor(s, sourceKeyField);
if (!targetContainsKey(targets, key, targetKeyField)) missing.push(s);
const key = sourceKeyFor(s, keyField);
if (!targetContainsKey(targets, key)) missing.push(s);
}
if (missing.length === 0) {
return { action: "skip", reason: "no_missing_edge_artifacts" };
}
const label = context.label ?? "edge";
const maxItems = context.max_items ?? 5;
const list = formatArtifactList(missing, maxItems);
const label = context.edge_label ?? "artifact edge";
const maxItems = Number(context.max_items ?? 5);
const list = missing
.slice(0, maxItems)
.map((a) => `- ${sourceKeyFor(a, keyField)} (${a.path})`)
.join("\n");
const hiddenCount = Math.max(0, missing.length - maxItems);
const suffix = hiddenCount > 0 ? `\n- ... ${hiddenCount} more` : "";
const message =
context.message_template ??
`Artifact edge repair needed: ${label} has ${missing.length} upstream artifact(s) with no matching downstream artifact.\n\n${list}`;
job.message ??
`Artifact edge repair needed: ${label} has ${missing.length} upstream artifact(s) with no matching downstream artifact.\n${list}${suffix}\n\n` +
"Producer loop owns creating the missing downstream artifact. " +
"Do not wait for orch to bridge this manually; repair the edge or record the blocker with evidence.";
return {
action: "send",
target: targetSession,
target: job.target,
message,
notes: { missing_count: missing.length, label },
};
@@ -1,38 +1,36 @@
// PL-004 Phase C: periodic-reminder policy (TypeScript port of
// POC `lib/policies/periodic-reminder.mjs`, 15 lines).
// POC `lib/policies/periodic-reminder.mjs`).
//
// Contract: explicit context.target.session and context.message both
// required. Throws for missing either (hard contract violation).
// Returns action=send unconditionally when invoked (the scheduler
// gates on interval; this policy itself is stateless).
// POC contract: target.session is required (top-level `target:` in
// spec_yaml). Message comes from `job.message` (top-level) OR
// `context.message`. Returns action=send unconditionally when invoked
// (the scheduler gates on interval; this policy itself is stateless).
import type { Policy, PolicyEvaluation, PolicyJob } from "./types.js";
interface PeriodicReminderContext {
target?: { session?: string };
message?: string;
}
export const periodicReminderPolicy: Policy = {
name: "periodic-reminder",
async evaluate(job: PolicyJob): Promise<PolicyEvaluation> {
if (!job.target?.session) {
throw Object.assign(new Error("periodic-reminder: target.session is required"), {
code: "policy_spec_invalid",
policy: "periodic-reminder",
field: "target.session",
});
}
const context = job.context as PeriodicReminderContext;
const targetSession = context.target?.session;
const message = context.message;
if (!targetSession) {
throw Object.assign(new Error("periodic-reminder requires context.target.session"), {
code: "policy_spec_invalid",
policy: "periodic-reminder",
field: "context.target.session",
});
}
const message = job.message ?? context.message;
if (!message) {
throw Object.assign(new Error("periodic-reminder requires context.message"), {
throw Object.assign(new Error("periodic-reminder: message is required (top-level message or context.message)"), {
code: "policy_spec_invalid",
policy: "periodic-reminder",
field: "context.message",
field: "message",
});
}
return { action: "send", target: targetSession, message };
return { action: "send", target: job.target, message };
},
};
+9 -2
View File
@@ -9,7 +9,14 @@
export interface PolicyJob {
jobId: string;
policy: string;
targetSession: string;
/**
* POC-shape target object. Built from spec_yaml top-level `target:`
* block when present; otherwise falls back to `{session: registered
* targetSession}`. Policies access `job.target.session` per POC.
*/
target: { session: string };
/** Optional top-level message override (POC pattern: `job.message`). */
message?: string;
intervalSeconds: number;
activeWakeIntervalSeconds: number | null;
scanIntervalSeconds: number | null;
@@ -22,7 +29,7 @@ export interface PolicyJob {
}
export type PolicyEvaluation =
| { action: "send"; target: string; message: string; notes?: Record<string, unknown> }
| { action: "send"; target: { session: string }; message: string; notes?: Record<string, unknown> }
| { action: "skip"; reason: string; notes?: Record<string, unknown> }
| { action: "terminal"; reason: string; notes?: Record<string, unknown> };
@@ -36,6 +36,8 @@ export interface WatchdogJob {
scanIntervalSeconds: number | null;
lastEvaluationAt: string | null;
lastFireAt: string | null;
actionable: boolean;
lastActionableAt: string | null;
state: WatchdogJobState;
registeredBySession: string;
registeredAt: string;
@@ -62,6 +64,8 @@ interface JobRow {
scan_interval_seconds: number | null;
last_evaluation_at: string | null;
last_fire_at: string | null;
actionable: number;
last_actionable_at: string | null;
state: string;
registered_by_session: string;
registered_at: string;
@@ -186,6 +190,38 @@ export class WatchdogJobsRepository {
}
}
/**
* R1 fix: write the actionable-state machine columns. Mirrors POC
* engine's `state.actionable` + `state.last_actionable_at`. Called
* by the policy engine after every meaningful evaluation:
* - newActionable=false (skip): clears actionable + last_actionable_at.
* - newActionable=true with no preserveLastActionableAt: stamps
* last_actionable_at to evaluatedAt (newly actionable).
* - newActionable=true with preserveLastActionableAt set: keeps the
* existing first-actionable timestamp (continued actionable window).
*/
setActionable(
jobId: string,
newActionable: boolean,
evaluatedAt: string,
preserveLastActionableAt: string | null = null,
): void {
if (!newActionable) {
this.db
.prepare(
`UPDATE watchdog_jobs SET actionable = 0, last_actionable_at = NULL WHERE job_id = ?`,
)
.run(jobId);
return;
}
const stamp = preserveLastActionableAt ?? evaluatedAt;
this.db
.prepare(
`UPDATE watchdog_jobs SET actionable = 1, last_actionable_at = ? WHERE job_id = ?`,
)
.run(stamp, jobId);
}
markTerminal(jobId: string, reason: string): void {
this.db
.prepare(
@@ -222,6 +258,8 @@ function rowToJob(row: JobRow): WatchdogJob {
scanIntervalSeconds: row.scan_interval_seconds,
lastEvaluationAt: row.last_evaluation_at,
lastFireAt: row.last_fire_at,
actionable: row.actionable !== 0,
lastActionableAt: row.last_actionable_at,
state: row.state as WatchdogJobState,
registeredBySession: row.registered_by_session,
registeredAt: row.registered_at,
@@ -10,21 +10,28 @@ import {
import type { WatchdogHistoryEntry, WatchdogHistoryLog } from "./watchdog-history-log.js";
/**
* Watchdog policy engine (PL-004 Phase C).
* Watchdog policy engine (PL-004 Phase C R1).
*
* Owns:
* 1. Policy resolution by name (registry).
* 2. spec_yaml → context parse for the policy.
* 3. Dispatch evaluate(job) → PolicyEvaluation.
* 4. For action=send: invoke delivery callback, record history,
* emit watchdog.evaluation_fired.
* 5. For action=skip: record history, emit watchdog.evaluation_skipped.
* 6. For action=terminal: mark job terminal, record history, emit
* watchdog.evaluation_terminal.
* Owns the per-evaluation state machine that mirrors POC engine
* `lib/engine.mjs`:
* 1. Resolve policy by name from registry. Unknown policy → terminal.
* 2. Parse spec_yaml into top-level `target:` + top-level `message?:` +
* nested `context:` block, then build a PolicyJob with the
* resolved target object (falls back to registered targetSession
* when spec lacks an explicit `target:`).
* 3. Dispatch policy.evaluate(policyJob).
* 4. Action handling:
* - skip: clear actionable=false; record history + emit event
* ONLY if reason is "loud" (not in QUIET_SKIP_REASONS).
* - send: enforce active-wake throttle. If state.actionable was
* already true AND active_wake_interval_seconds is set AND not
* elapsed since last_fire_at → emit/record `active_wake_not_due`
* (quiet skip per POC). Otherwise call delivery, record history
* with sent outcome, emit evaluation_fired, set actionable=true.
* - terminal: mark job terminal, record history, emit terminal.
*
* The actual delivery is injected as a callback so tests can stub it
* and so the daemon supervision tree can wire it to SessionTransport
* without this module needing a concrete dependency.
* `not_due` polls are filtered upstream by the scheduler and never
* reach this engine.
*/
export interface DeliveryRequest {
@@ -40,8 +47,16 @@ export interface DeliveryOutcome {
export type DeliveryFn = (req: DeliveryRequest) => Promise<DeliveryOutcome>;
export interface PolicyContextParser {
/** Parse the operator-supplied spec_yaml into the `context:` block. */
(specYaml: string): Record<string, unknown>;
/**
* Parse the operator-supplied spec_yaml into the structured fields the
* engine needs. Returns the top-level `target` (object), top-level
* `message` (string), and the `context:` block (Record).
*/
(specYaml: string): {
target: { session: string } | null;
message: string | null;
context: Record<string, unknown>;
};
}
interface WatchdogPolicyEngineDeps {
@@ -49,8 +64,7 @@ interface WatchdogPolicyEngineDeps {
historyLog: WatchdogHistoryLog;
eventBus: EventBus;
deliver: DeliveryFn;
/** Optional override: defaults to a YAML-aware parser. */
parseContext?: PolicyContextParser;
parseSpec?: PolicyContextParser;
now?: () => Date;
}
@@ -60,11 +74,26 @@ const POLICY_REGISTRY: Map<string, Policy> = new Map([
[edgeArtifactRequiredPolicy.name, edgeArtifactRequiredPolicy],
]);
/**
* Quiet skip reasons — POC `shouldAppendHistory` (engine.mjs:99-112)
* suppresses these from history. Same set must NOT emit watchdog.*
* events for parity with POC SSE behavior; agents never see scheduler
* polls just because the pool was empty or the wake throttle was active.
*/
const QUIET_SKIP_REASONS = new Set<string>([
"not_due",
"no_actionable_artifacts",
"no_missing_edge_artifacts",
"active_wake_not_due",
]);
export interface EvaluationResult {
job: WatchdogJob;
outcome: PolicyEvaluation;
outcome: PolicyEvaluation | { action: "skip"; reason: "active_wake_not_due" };
history: WatchdogHistoryEntry | null;
delivery: DeliveryOutcome | null;
/** True if this evaluation produced a history record + event. */
meaningful: boolean;
}
export class WatchdogPolicyEngine {
@@ -72,7 +101,7 @@ export class WatchdogPolicyEngine {
private readonly historyLog: WatchdogHistoryLog;
private readonly eventBus: EventBus;
private readonly deliver: DeliveryFn;
private readonly parseContext: PolicyContextParser;
private readonly parseSpec: PolicyContextParser;
private readonly now: () => Date;
constructor(deps: WatchdogPolicyEngineDeps) {
@@ -80,7 +109,7 @@ export class WatchdogPolicyEngine {
this.historyLog = deps.historyLog;
this.eventBus = deps.eventBus;
this.deliver = deps.deliver;
this.parseContext = deps.parseContext ?? defaultParseContext;
this.parseSpec = deps.parseSpec ?? defaultParseSpec;
this.now = deps.now ?? (() => new Date());
}
@@ -88,21 +117,13 @@ export class WatchdogPolicyEngine {
return POLICY_REGISTRY.get(name);
}
/**
* Evaluate one watchdog job. Side-effects: history record (for
* meaningful outcomes), event emission, and (on send) delivery.
*
* Pure `not_due` decisions are NOT this engine's concern; the
* scheduler decides dueness and only calls evaluate for due jobs.
* If the policy itself returns skip with an internal reason
* (no_actionable_artifacts, etc.) we DO record that.
*/
async evaluate(job: WatchdogJob): Promise<EvaluationResult> {
const policy = this.resolvePolicy(job.policy);
const evaluatedAt = this.now().toISOString();
if (!policy) {
const reason = `unknown_policy:${job.policy}`;
this.jobsRepo.markTerminal(job.jobId, reason);
const evaluatedAt = this.now().toISOString();
const history = this.historyLog.record({
jobId: job.jobId,
evaluatedAt,
@@ -120,18 +141,21 @@ export class WatchdogPolicyEngine {
outcome: { action: "terminal", reason },
history,
delivery: null,
meaningful: true,
};
}
const context = this.parseContext(job.specYaml);
const parsed = this.parseSpec(job.specYaml);
const target = parsed.target ?? { session: job.targetSession };
const policyJob: PolicyJob = {
jobId: job.jobId,
policy: job.policy,
targetSession: job.targetSession,
target,
message: parsed.message ?? undefined,
intervalSeconds: job.intervalSeconds,
activeWakeIntervalSeconds: job.activeWakeIntervalSeconds,
scanIntervalSeconds: job.scanIntervalSeconds,
context,
context: parsed.context,
lastEvaluationAt: job.lastEvaluationAt,
lastFireAt: job.lastFireAt,
registeredBySession: job.registeredBySession,
@@ -139,39 +163,22 @@ export class WatchdogPolicyEngine {
};
const outcome = await policy.evaluate(policyJob);
const evaluatedAt = this.now().toISOString();
if (outcome.action === "send") {
const delivery = await this.deliver({
targetSession: outcome.target,
message: outcome.message,
});
const history = this.historyLog.record({
jobId: job.jobId,
evaluatedAt,
outcome: "sent",
deliveryTargetSession: outcome.target,
deliveryStatus: delivery.status,
deliveryMessage: outcome.message,
evaluationNotes: outcome.notes ?? null,
});
this.jobsRepo.recordEvaluation(job.jobId, evaluatedAt, true);
this.eventBus.emit({
type: "watchdog.evaluation_fired",
jobId: job.jobId,
policy: job.policy,
targetSession: outcome.target,
deliveryStatus: delivery.status,
});
return {
job: this.jobsRepo.getByIdOrThrow(job.jobId),
outcome,
history,
delivery,
};
}
if (outcome.action === "skip") {
// POC parity: skip clears actionable. Loud-vs-quiet decides
// whether to record + emit.
this.jobsRepo.recordEvaluation(job.jobId, evaluatedAt, false);
this.jobsRepo.setActionable(job.jobId, false, evaluatedAt);
const isQuiet = QUIET_SKIP_REASONS.has(outcome.reason);
if (isQuiet) {
return {
job: this.jobsRepo.getByIdOrThrow(job.jobId),
outcome,
history: null,
delivery: null,
meaningful: false,
};
}
const history = this.historyLog.record({
jobId: job.jobId,
evaluatedAt,
@@ -179,7 +186,6 @@ export class WatchdogPolicyEngine {
skipReason: outcome.reason,
evaluationNotes: outcome.notes ?? null,
});
this.jobsRepo.recordEvaluation(job.jobId, evaluatedAt, false);
this.eventBus.emit({
type: "watchdog.evaluation_skipped",
jobId: job.jobId,
@@ -191,91 +197,196 @@ export class WatchdogPolicyEngine {
outcome,
history,
delivery: null,
meaningful: true,
};
}
// terminal
this.jobsRepo.markTerminal(job.jobId, outcome.reason);
if (outcome.action === "terminal") {
this.jobsRepo.markTerminal(job.jobId, outcome.reason);
const history = this.historyLog.record({
jobId: job.jobId,
evaluatedAt,
outcome: "terminal",
skipReason: outcome.reason,
evaluationNotes: outcome.notes ?? null,
});
this.eventBus.emit({
type: "watchdog.evaluation_terminal",
jobId: job.jobId,
policy: job.policy,
terminalReason: outcome.reason,
});
return {
job: this.jobsRepo.getByIdOrThrow(job.jobId),
outcome,
history,
delivery: null,
meaningful: true,
};
}
// outcome.action === "send".
// POC active-wake throttle (engine.mjs:49-64, :243-263):
// - If state.actionable was already true AND active_wake_interval
// is set AND wake-window has not elapsed → quiet skip. Preserves
// existing last_fire_at and last_actionable_at.
// - Otherwise: deliver, set actionable=true, stamp last_fire_at +
// last_actionable_at (preserve existing first-actionable timestamp).
if (
job.actionable &&
job.activeWakeIntervalSeconds !== null &&
job.lastFireAt !== null
) {
const lastFireMs = Date.parse(job.lastFireAt);
const nowMs = Date.parse(evaluatedAt);
const intervalMs = job.activeWakeIntervalSeconds * 1000;
if (Number.isFinite(lastFireMs) && nowMs - lastFireMs < intervalMs) {
// Quiet skip: scan happened, pool still actionable, but the
// wake window is closed. Update last_evaluation_at, do NOT
// touch last_fire_at, keep actionable=true and preserve
// last_actionable_at.
this.jobsRepo.recordEvaluation(job.jobId, evaluatedAt, false);
this.jobsRepo.setActionable(job.jobId, true, evaluatedAt, job.lastActionableAt);
return {
job: this.jobsRepo.getByIdOrThrow(job.jobId),
outcome: { action: "skip", reason: "active_wake_not_due" },
history: null,
delivery: null,
meaningful: false,
};
}
}
const delivery = await this.deliver({
targetSession: outcome.target.session,
message: outcome.message,
});
const history = this.historyLog.record({
jobId: job.jobId,
evaluatedAt,
outcome: "terminal",
skipReason: outcome.reason,
outcome: "sent",
deliveryTargetSession: outcome.target.session,
deliveryStatus: delivery.status,
deliveryMessage: outcome.message,
evaluationNotes: outcome.notes ?? null,
});
this.jobsRepo.recordEvaluation(job.jobId, evaluatedAt, true);
this.jobsRepo.setActionable(job.jobId, true, evaluatedAt, job.lastActionableAt);
this.eventBus.emit({
type: "watchdog.evaluation_terminal",
type: "watchdog.evaluation_fired",
jobId: job.jobId,
policy: job.policy,
terminalReason: outcome.reason,
targetSession: outcome.target.session,
deliveryStatus: delivery.status,
});
return {
job: this.jobsRepo.getByIdOrThrow(job.jobId),
outcome,
history,
delivery: null,
delivery,
meaningful: true,
};
}
}
/**
* Default spec_yaml parser. Extracts the `context:` block as a
* generic Record<string, unknown>. Uses a minimal recursive
* indentation-based parser sufficient for the POC spec shape:
* Default spec_yaml parser. Extracts:
* - top-level `target:` block (POC contract: `{session: "..."}`)
* - top-level `message:` scalar (POC pattern for periodic-reminder)
* - nested `context:` block as Record<string, unknown>
*
* Uses a minimal recursive indentation-based parser sufficient for the
* POC spec set:
* policy: ...
* target: <member>@<rig>
* target:
* session: ...
* message: "..."
* interval_seconds: 1800
* context:
* target:
* session: ...
* message: "..."
* pools:
* - path: /abs/...
* include_statuses: [ready]
*
* For robust YAML, a future maintenance pass can swap in `yaml` or
* For richer YAML, a future maintenance pass can swap in `yaml` /
* `js-yaml`; the POC schemas are simple enough that this parser
* handles them in-tree without an extra dependency.
*/
function defaultParseContext(specYaml: string): Record<string, unknown> {
function defaultParseSpec(specYaml: string): {
target: { session: string } | null;
message: string | null;
context: Record<string, unknown>;
} {
const lines = specYaml.split("\n");
const result: { target: { session: string } | null; message: string | null; context: Record<string, unknown> } = {
target: null,
message: null,
context: {},
};
let i = 0;
while (i < lines.length) {
const line = lines[i] ?? "";
if (/^context\s*:\s*$/.test(line)) {
if (line.trimStart() !== line) {
// Indented line at top level — skip until we re-hit indent 0.
i++;
const block: string[] = [];
while (i < lines.length) {
const sub = lines[i] ?? "";
if (sub.length === 0) {
block.push(sub);
i++;
continue;
}
if (sub.startsWith(" ") || sub.startsWith("\t")) {
block.push(sub);
i++;
continue;
}
break;
}
// Strip leading two-space indent to make it a top-level YAML doc.
const dedented = block
.map((l) => (l.startsWith(" ") ? l.slice(2) : l))
.join("\n");
return parseSimpleYaml(dedented) as Record<string, unknown>;
continue;
}
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith("#")) {
i++;
continue;
}
const colonIdx = trimmed.indexOf(":");
if (colonIdx === -1) {
i++;
continue;
}
const key = trimmed.slice(0, colonIdx).trim();
const value = trimmed.slice(colonIdx + 1).trim();
if (value.length > 0) {
// Inline scalar.
if (key === "message") {
result.message = stripQuotes(value);
}
i++;
continue;
}
// Block. Collect indented lines below.
i++;
const block: string[] = [];
while (i < lines.length) {
const sub = lines[i] ?? "";
if (sub.length === 0) {
block.push(sub);
i++;
continue;
}
if (sub.startsWith(" ") || sub.startsWith("\t")) {
block.push(sub);
i++;
continue;
}
break;
}
const dedented = block.map((l) => (l.startsWith(" ") ? l.slice(2) : l)).join("\n");
if (key === "context") {
result.context = parseSimpleYaml(dedented) as Record<string, unknown>;
} else if (key === "target") {
const parsed = parseSimpleYaml(dedented);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const sess = (parsed as Record<string, unknown>).session;
if (typeof sess === "string") result.target = { session: sess };
}
}
}
return {};
return result;
}
function stripQuotes(value: string): string {
if (value.startsWith('"') && value.endsWith('"')) return value.slice(1, -1);
if (value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
return value;
}
/**
* Minimal indentation-aware YAML parser. Supports nested mappings,
* inline `[a, b]` arrays, dash-prefixed sequences of mappings, and
* scalar string values (quoted or unquoted). Sufficient for the POC
* spec set; not a general YAML implementation.
*/
function parseSimpleYaml(src: string): unknown {
const lines = src.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
const idx = { i: 0 };
@@ -300,9 +411,7 @@ function parseScalar(raw: string): unknown {
if (inner === "") return [];
return inner.split(",").map((p) => parseScalar(p));
}
if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed.slice(1, -1);
if (trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed.slice(1, -1);
return trimmed;
return stripQuotes(trimmed);
}
function parseYamlBlock(lines: string[], idx: { i: number }, expectedIndent: number): unknown {
@@ -337,7 +446,6 @@ function parseYamlMap(
if (valuePart.length > 0) {
result[key] = parseScalar(valuePart);
} else {
// Nested block: probe next line indent.
if (idx.i < lines.length) {
const next = lines[idx.i] ?? "";
const nextIndent = indentOf(next);
@@ -368,9 +476,6 @@ function parseYamlSeq(
const rest = trimmed.slice(2).trim();
idx.i++;
if (rest.includes(":")) {
// First key of an inline mapping element. Synthesize a virtual
// sub-block by re-injecting the rest as a key + scanning further
// lines at deeper indent.
const colonIdx = rest.indexOf(":");
const k = rest.slice(0, colonIdx).trim();
const v = rest.slice(colonIdx + 1).trim();
@@ -382,7 +487,6 @@ function parseYamlSeq(
} else {
elem[k] = null;
}
// Continue gathering same-element keys at indent blockIndent + 2.
while (idx.i < lines.length) {
const sub = lines[idx.i] ?? "";
const subIndent = indentOf(sub);
@@ -138,9 +138,17 @@ export class WatchdogScheduler {
}
}
/**
* R1 fix: scan cadence is governed by `scan_interval_seconds` when
* supplied, falling back to `interval_seconds`. Mirrors POC engine
* (lib/engine.mjs:38-46) where `last_scan_at` and `scan_interval_seconds`
* gate the policy invocation. Wake-cadence (`active_wake_interval_seconds`)
* is enforced one layer up by the policy engine, NOT here.
*/
export function isDue(job: WatchdogJob, nowMs: number): boolean {
if (!job.lastEvaluationAt) return true;
const last = Date.parse(job.lastEvaluationAt);
if (Number.isNaN(last)) return true;
return nowMs - last >= job.intervalSeconds * 1000;
const cadenceSeconds = job.scanIntervalSeconds ?? job.intervalSeconds;
return nowMs - last >= cadenceSeconds * 1000;
}
@@ -35,11 +35,13 @@ describe("WatchdogJobsRepository (PL-004 Phase C)", () => {
};
}
it("register stores all three v1 policies", () => {
it("register stores all three v1 policies + actionable defaults to false", () => {
for (const p of PHASE_C_POLICIES) {
const job = repo.register(validInput({ policy: p }));
expect(job.policy).toBe(p);
expect(job.state).toBe("active");
expect(job.actionable).toBe(false);
expect(job.lastActionableAt).toBeNull();
expect(job.jobId).toMatch(/^[0-9A-Z]{26}$/);
}
});
@@ -138,6 +140,31 @@ describe("WatchdogJobsRepository (PL-004 Phase C)", () => {
}
});
it("setActionable(true) sets actionable=1 + last_actionable_at to evaluatedAt by default", () => {
const job = repo.register(validInput());
repo.setActionable(job.jobId, true, "2026-05-03T07:00:00.000Z");
const after = repo.getByIdOrThrow(job.jobId);
expect(after.actionable).toBe(true);
expect(after.lastActionableAt).toBe("2026-05-03T07:00:00.000Z");
});
it("setActionable(true) preserves last_actionable_at when preserve arg passed (continued window)", () => {
const job = repo.register(validInput());
repo.setActionable(job.jobId, true, "2026-05-03T07:00:00.000Z");
repo.setActionable(job.jobId, true, "2026-05-03T07:01:00.000Z", "2026-05-03T07:00:00.000Z");
const after = repo.getByIdOrThrow(job.jobId);
expect(after.lastActionableAt).toBe("2026-05-03T07:00:00.000Z");
});
it("setActionable(false) clears actionable + last_actionable_at", () => {
const job = repo.register(validInput());
repo.setActionable(job.jobId, true, "2026-05-03T07:00:00.000Z");
repo.setActionable(job.jobId, false, "2026-05-03T07:01:00.000Z");
const after = repo.getByIdOrThrow(job.jobId);
expect(after.actionable).toBe(false);
expect(after.lastActionableAt).toBeNull();
});
it("getByIdOrThrow throws job_not_found for unknown id", () => {
try {
repo.getByIdOrThrow("does-not-exist");
+203 -70
View File
@@ -11,7 +11,7 @@ function makeJob(overrides: Partial<PolicyJob> & { context: Record<string, unkno
return {
jobId: "job-1",
policy: "periodic-reminder",
targetSession: "a@rig",
target: { session: "a@rig" },
intervalSeconds: 60,
activeWakeIntervalSeconds: null,
scanIntervalSeconds: null,
@@ -23,28 +23,42 @@ function makeJob(overrides: Partial<PolicyJob> & { context: Record<string, unkno
};
}
describe("periodicReminderPolicy", () => {
it("returns send with explicit target+message from context", async () => {
describe("periodicReminderPolicy (POC contract)", () => {
it("returns send with target object + message from job.message", async () => {
const out = await periodicReminderPolicy.evaluate(
makeJob({
context: { target: { session: "alice@rig" }, message: "ping" },
target: { session: "alice@rig" },
message: "ping",
context: {},
}),
);
expect(out).toEqual({ action: "send", target: "alice@rig", message: "ping" });
expect(out).toEqual({ action: "send", target: { session: "alice@rig" }, message: "ping" });
});
it("throws policy_spec_invalid when context.target.session is missing", async () => {
it("returns send with message from context.message when job.message absent", async () => {
const out = await periodicReminderPolicy.evaluate(
makeJob({
target: { session: "alice@rig" },
context: { message: "ctx-ping" },
}),
);
expect(out).toEqual({ action: "send", target: { session: "alice@rig" }, message: "ctx-ping" });
});
it("throws policy_spec_invalid when target.session is missing", async () => {
try {
await periodicReminderPolicy.evaluate(makeJob({ context: { message: "x" } }));
await periodicReminderPolicy.evaluate(
makeJob({ target: { session: "" }, context: { message: "x" } }),
);
throw new Error("should have thrown");
} catch (err) {
expect((err as Error & { code: string }).code).toBe("policy_spec_invalid");
}
});
it("throws policy_spec_invalid when context.message is missing", async () => {
it("throws policy_spec_invalid when no message anywhere", async () => {
try {
await periodicReminderPolicy.evaluate(makeJob({ context: { target: { session: "a@rig" } } }));
await periodicReminderPolicy.evaluate(makeJob({ context: {} }));
throw new Error("should have thrown");
} catch (err) {
expect((err as Error & { code: string }).code).toBe("policy_spec_invalid");
@@ -52,7 +66,7 @@ describe("periodicReminderPolicy", () => {
});
});
describe("artifactPoolReadyPolicy", () => {
describe("artifactPoolReadyPolicy (POC contract)", () => {
let tmp: string;
beforeEach(() => {
tmp = join(tmpdir(), `watchdog-pool-${Date.now()}-${Math.random()}`);
@@ -63,7 +77,7 @@ describe("artifactPoolReadyPolicy", () => {
it("skip with reason no_actionable_artifacts when pool empty", async () => {
const out = await artifactPoolReadyPolicy.evaluate(
makeJob({
context: { target: { session: "a@rig" }, pools: { path: tmp } },
context: { pools: [{ path: tmp }] },
}),
);
expect(out).toEqual({ action: "skip", reason: "no_actionable_artifacts" });
@@ -74,19 +88,21 @@ describe("artifactPoolReadyPolicy", () => {
writeFileSync(join(tmp, "b.md"), "---\nstatus: ready\n---\nbody-b\n");
const out = await artifactPoolReadyPolicy.evaluate(
makeJob({
target: { session: "a@rig" },
context: {
target: { session: "a@rig" },
pools: { path: tmp, include_statuses: ["ready"] },
pools: [{ path: tmp, include_statuses: ["ready"] }],
label: "things",
},
}),
);
expect(out.action).toBe("send");
if (out.action !== "send") return;
expect(out.target).toBe("a@rig");
expect(out.target).toEqual({ session: "a@rig" });
expect(out.message).toMatch(/things has 2 actionable artifact/);
expect(out.message).toContain("a.md");
expect(out.message).toContain("b.md");
// POC trailer message
expect(out.message).toContain("Claim and process the next artifact");
});
it("respects include_statuses filter (artifact with non-matching status is excluded)", async () => {
@@ -94,10 +110,7 @@ describe("artifactPoolReadyPolicy", () => {
writeFileSync(join(tmp, "b.md"), "---\nstatus: draft\n---\nbody-b\n");
const out = await artifactPoolReadyPolicy.evaluate(
makeJob({
context: {
target: { session: "a@rig" },
pools: { path: tmp, include_statuses: ["ready"] },
},
context: { pools: [{ path: tmp, include_statuses: ["ready"] }] },
}),
);
expect(out.action).toBe("send");
@@ -107,15 +120,14 @@ describe("artifactPoolReadyPolicy", () => {
expect(out.message).not.toContain("b.md");
});
it("respects max_items cap in formatted bullet list", async () => {
it("respects max_items cap (formatted bullet list capped)", async () => {
for (const c of ["a", "b", "c", "d", "e", "f", "g"]) {
writeFileSync(join(tmp, `${c}.md`), "---\nstatus: ready\n---\n");
}
const out = await artifactPoolReadyPolicy.evaluate(
makeJob({
context: {
target: { session: "a@rig" },
pools: { path: tmp, include_statuses: ["ready"] },
pools: [{ path: tmp, include_statuses: ["ready"] }],
max_items: 3,
},
}),
@@ -123,22 +135,115 @@ describe("artifactPoolReadyPolicy", () => {
expect(out.action).toBe("send");
if (out.action !== "send") return;
expect(out.message).toMatch(/has 7 actionable/);
const bulletCount = out.message.split("\n").filter((l) => l.startsWith("- ")).length;
const bulletCount = out.message
.split("\n")
.filter((l) => l.startsWith("- ") && !l.startsWith("- ..."))
.length;
expect(bulletCount).toBe(3);
expect(out.message).toContain("- ... 4 more");
});
it("missing pool directory yields skip (no actionable)", async () => {
it("missing pool directory yields skip (ENOENT-tolerant)", async () => {
rmSync(tmp, { recursive: true, force: true });
const out = await artifactPoolReadyPolicy.evaluate(
makeJob({
context: { target: { session: "a@rig" }, pools: { path: tmp } },
context: { pools: [{ path: tmp }] },
}),
);
expect(out).toEqual({ action: "skip", reason: "no_actionable_artifacts" });
});
// R1 fix: POC scanner parity (guard blocker 3).
it("default-ignores README.md (POC parity)", async () => {
writeFileSync(join(tmp, "README.md"), "# Pool docs\n");
writeFileSync(join(tmp, "ready.md"), "---\nstatus: ready\n---\n");
const out = await artifactPoolReadyPolicy.evaluate(
makeJob({
context: { pools: [{ path: tmp, include_statuses: ["ready"] }] },
}),
);
expect(out.action).toBe("send");
if (out.action !== "send") return;
expect(out.message).toContain("ready.md");
expect(out.message).not.toContain("README.md");
});
it("default-ignores .DS_Store (POC parity)", async () => {
writeFileSync(join(tmp, ".DS_Store"), "binary-junk");
writeFileSync(join(tmp, "ready.md"), "---\nstatus: ready\n---\n");
const out = await artifactPoolReadyPolicy.evaluate(
makeJob({
context: { pools: [{ path: tmp, include_statuses: ["ready"] }] },
}),
);
expect(out.action).toBe("send");
if (out.action !== "send") return;
expect(out.message).not.toContain(".DS_Store");
});
it("excludes malformed-frontmatter artifacts unless include_malformed_frontmatter=true", async () => {
writeFileSync(join(tmp, "ready.md"), "---\nstatus: ready\n---\n");
writeFileSync(
join(tmp, "malformed.md"),
"---\nstatus: ready\nbroken: value: still broken\n---\n",
);
const out = await artifactPoolReadyPolicy.evaluate(
makeJob({
context: { pools: [{ path: tmp, include_statuses: ["ready"] }] },
}),
);
expect(out.action).toBe("send");
if (out.action !== "send") return;
expect(out.message).toContain("ready.md");
expect(out.message).not.toContain("malformed.md");
});
it("supports configured ignore_names (POC parity)", async () => {
writeFileSync(join(tmp, "skip-me.md"), "---\nstatus: ready\n---\n");
writeFileSync(join(tmp, "include-me.md"), "---\nstatus: ready\n---\n");
const out = await artifactPoolReadyPolicy.evaluate(
makeJob({
context: {
pools: [{ path: tmp, include_statuses: ["ready"], ignore_names: ["skip-me.md"] }],
},
}),
);
expect(out.action).toBe("send");
if (out.action !== "send") return;
expect(out.message).toContain("include-me.md");
expect(out.message).not.toContain("skip-me.md");
});
it("recursive=true descends into subdirectories", async () => {
const sub = join(tmp, "sub");
mkdirSync(sub, { recursive: true });
writeFileSync(join(sub, "nested-ready.md"), "---\nstatus: ready\n---\n");
const out = await artifactPoolReadyPolicy.evaluate(
makeJob({
context: {
pools: [{ path: tmp, include_statuses: ["ready"], recursive: true }],
},
}),
);
expect(out.action).toBe("send");
if (out.action !== "send") return;
expect(out.message).toContain("nested-ready.md");
});
it("recursive default=false excludes subdirectories", async () => {
const sub = join(tmp, "sub");
mkdirSync(sub, { recursive: true });
writeFileSync(join(sub, "nested-ready.md"), "---\nstatus: ready\n---\n");
const out = await artifactPoolReadyPolicy.evaluate(
makeJob({
context: { pools: [{ path: tmp, include_statuses: ["ready"] }] },
}),
);
expect(out).toEqual({ action: "skip", reason: "no_actionable_artifacts" });
});
});
describe("edgeArtifactRequiredPolicy", () => {
describe("edgeArtifactRequiredPolicy (POC contract)", () => {
let src: string;
let tgt: string;
beforeEach(() => {
@@ -152,89 +257,117 @@ describe("edgeArtifactRequiredPolicy", () => {
rmSync(tgt, { recursive: true, force: true });
});
it("skip when every source key has matching target", async () => {
writeFileSync(join(src, "x.md"), "---\nentry: x\nstatus: ready\n---\n");
writeFileSync(join(tgt, "x-downstream.md"), "---\nentry: x\nstatus: anything\n---\n");
// R1 fix (guard blocker 2): POC contract uses context.source / context.target.
it("skip when downstream raw content references source key (POC body-match)", async () => {
writeFileSync(
join(src, "x.md"),
"---\nentry: coordination-stream-queue-view-intake-pilot-a-vertical\nstatus: shipped\n---\n# Pilot A\n",
);
writeFileSync(
join(tgt, "old.md"),
"---\nstatus: closed\n---\n# Old item\n\nThis item mentions agent-starter-v1-vertical only.\n",
);
writeFileSync(
join(tgt, "new.md"),
"---\nstatus: ready\n---\nLifecycle edge for coordination-stream-queue-view-intake-pilot-a-vertical.\n",
);
const out = await edgeArtifactRequiredPolicy.evaluate(
makeJob({
target: { session: "delivery-orch-lead@openrig-velocity" },
context: {
target: { session: "a@rig" },
source_pools: { path: src, include_statuses: ["ready"] },
target_pools: { path: tgt },
edge_label: "delivery-to-lifecycle",
source: { path: src, include_statuses: ["shipped"], key_field: "entry" },
target: { path: tgt },
},
}),
);
expect(out).toEqual({ action: "skip", reason: "no_missing_edge_artifacts" });
});
it("send with missing-edge list when sources lack matching targets", async () => {
writeFileSync(join(src, "x.md"), "---\nentry: x\nstatus: ready\n---\n");
writeFileSync(join(src, "y.md"), "---\nentry: y\nstatus: ready\n---\n");
writeFileSync(join(tgt, "x-downstream.md"), "---\nentry: x\n---\n");
it("send when downstream raw does NOT reference source key (POC body-match)", async () => {
writeFileSync(
join(src, "x.md"),
"---\nentry: coordination-stream-queue-view-intake-pilot-a-vertical\nstatus: shipped\n---\n# Pilot A\n",
);
writeFileSync(
join(tgt, "old.md"),
"---\nstatus: closed\n---\n# Old item\n\nThis item mentions agent-starter-v1-vertical only.\n",
);
const out = await edgeArtifactRequiredPolicy.evaluate(
makeJob({
target: { session: "delivery-orch-lead@openrig-velocity" },
context: {
target: { session: "a@rig" },
source_pools: { path: src, include_statuses: ["ready"] },
target_pools: { path: tgt },
label: "myedge",
edge_label: "delivery-to-lifecycle",
source: { path: src, include_statuses: ["shipped"], key_field: "entry" },
target: { path: tgt },
},
}),
);
expect(out.action).toBe("send");
if (out.action !== "send") return;
expect(out.target).toBe("a@rig");
expect(out.message).toMatch(/myedge has 1 upstream artifact/);
expect(out.message).toContain("y.md");
expect(out.message).not.toContain("x.md");
expect(out.target).toEqual({ session: "delivery-orch-lead@openrig-velocity" });
expect(out.message).toContain("delivery-to-lifecycle");
expect(out.message).toContain("coordination-stream-queue-view-intake-pilot-a-vertical");
expect(out.message).toContain("Producer loop owns creating the missing downstream artifact");
});
it("source key falls back to basename-sans-md when frontmatter[key_field] absent", async () => {
writeFileSync(join(src, "no-frontmatter-key.md"), "---\nstatus: ready\n---\n");
const out = await edgeArtifactRequiredPolicy.evaluate(
makeJob({
target: { session: "a@rig" },
context: {
target: { session: "a@rig" },
source_pools: { path: src, include_statuses: ["ready"] },
target_pools: { path: tgt },
source: { path: src, include_statuses: ["ready"] },
target: { path: tgt },
},
}),
);
expect(out.action).toBe("send");
if (out.action !== "send") return;
expect(out.message).toContain("no-frontmatter-key.md");
expect(out.message).toContain("no-frontmatter-key");
});
it("target match honors target_key_field override", async () => {
writeFileSync(join(src, "x.md"), "---\nentry: foo\nstatus: ready\n---\n");
writeFileSync(join(tgt, "x.md"), "---\nlinks_to: foo\n---\n");
const out = await edgeArtifactRequiredPolicy.evaluate(
makeJob({
context: {
target: { session: "a@rig" },
source_pools: { path: src, include_statuses: ["ready"] },
target_pools: { path: tgt },
source_key_field: "entry",
target_key_field: "links_to",
},
}),
);
expect(out).toEqual({ action: "skip", reason: "no_missing_edge_artifacts" });
});
it("target scan ignores include_statuses (matches POC override)", async () => {
it("target scan ignores source's include_statuses (POC override)", async () => {
writeFileSync(join(src, "x.md"), "---\nentry: x\nstatus: ready\n---\n");
// Target has matching key but a non-listed status; match should still count.
writeFileSync(join(tgt, "x-down.md"), "---\nentry: x\nstatus: rejected\n---\n");
// Target has matching key in body but a non-listed status; match still counts.
writeFileSync(join(tgt, "x-down.md"), "---\nstatus: rejected\n---\nReferences x here.\n");
const out = await edgeArtifactRequiredPolicy.evaluate(
makeJob({
target: { session: "a@rig" },
context: {
target: { session: "a@rig" },
source_pools: { path: src, include_statuses: ["ready"] },
target_pools: { path: tgt, include_statuses: ["accepted"] },
source: { path: src, include_statuses: ["ready"] },
target: { path: tgt, include_statuses: ["accepted"] },
},
}),
);
expect(out).toEqual({ action: "skip", reason: "no_missing_edge_artifacts" });
});
it("throws policy_spec_invalid when context.source is missing", async () => {
try {
await edgeArtifactRequiredPolicy.evaluate(
makeJob({
target: { session: "a@rig" },
context: { target: { path: tgt } },
}),
);
throw new Error("should have thrown");
} catch (err) {
expect((err as Error & { code: string }).code).toBe("policy_spec_invalid");
}
});
it("throws policy_spec_invalid when context.target is missing", async () => {
try {
await edgeArtifactRequiredPolicy.evaluate(
makeJob({
target: { session: "a@rig" },
context: { source: { path: src } },
}),
);
throw new Error("should have thrown");
} catch (err) {
expect((err as Error & { code: string }).code).toBe("policy_spec_invalid");
}
});
});
@@ -1,4 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
@@ -15,7 +18,7 @@ import {
} from "../src/domain/watchdog-policy-engine.js";
import type { PersistedEvent } from "../src/domain/types.js";
describe("WatchdogPolicyEngine (PL-004 Phase C)", () => {
describe("WatchdogPolicyEngine (PL-004 Phase C R1)", () => {
let db: Database.Database;
let bus: EventBus;
let jobsRepo: WatchdogJobsRepository;
@@ -24,12 +27,13 @@ describe("WatchdogPolicyEngine (PL-004 Phase C)", () => {
let deliveryCalls: Array<{ targetSession: string; message: string }>;
let deliver: DeliveryFn;
function makeEngine(deliverOverride?: DeliveryFn): WatchdogPolicyEngine {
function makeEngine(opts?: { deliver?: DeliveryFn; now?: () => Date }): WatchdogPolicyEngine {
return new WatchdogPolicyEngine({
jobsRepo,
historyLog: log,
eventBus: bus,
deliver: deliverOverride ?? deliver,
deliver: opts?.deliver ?? deliver,
now: opts?.now,
});
}
@@ -50,61 +54,70 @@ describe("WatchdogPolicyEngine (PL-004 Phase C)", () => {
afterEach(() => db.close());
it("evaluate(periodic-reminder) routes through delivery + records sent + emits evaluation_fired", async () => {
it("evaluate(periodic-reminder) routes through delivery + records sent + emits evaluation_fired + sets actionable=true", async () => {
const engine = makeEngine();
const job = jobsRepo.register({
policy: "periodic-reminder",
specYaml: "policy: periodic-reminder\ntarget: alice@rig\ninterval_seconds: 60\ncontext:\n target:\n session: alice@rig\n message: ping\n",
specYaml:
"policy: periodic-reminder\ntarget:\n session: alice@rig\ninterval_seconds: 60\nmessage: ping\n",
targetSession: "alice@rig",
intervalSeconds: 60,
registeredBySession: "ops@kernel",
});
const result = await engine.evaluate(job);
expect(result.outcome.action).toBe("send");
await engine.evaluate(job);
expect(deliveryCalls).toEqual([{ targetSession: "alice@rig", message: "ping" }]);
expect(log.countForJob(job.jobId)).toBe(1);
const list = log.listForJob(job.jobId);
expect(list[0]?.outcome).toBe("sent");
expect(list[0]?.deliveryStatus).toBe("ok");
expect(captured.some((e) => e.type === "watchdog.evaluation_fired")).toBe(true);
const after = jobsRepo.getByIdOrThrow(job.jobId);
expect(after.lastEvaluationAt).not.toBeNull();
expect(after.lastFireAt).not.toBeNull();
expect(after.actionable).toBe(true);
expect(after.lastActionableAt).not.toBeNull();
});
it("evaluate records skipped + emits evaluation_skipped + does NOT touch last_fire_at", async () => {
const engine = makeEngine(async () => {
throw new Error("delivery should NOT be called for skip");
it("evaluate(quiet skip — no_actionable_artifacts) does NOT record history nor emit event (POC parity)", async () => {
const engine = makeEngine({
deliver: async () => {
throw new Error("delivery should NOT be called for skip");
},
});
// artifact-pool-ready against an empty pool returns skip(no_actionable_artifacts).
const job = jobsRepo.register({
policy: "artifact-pool-ready",
specYaml:
"policy: artifact-pool-ready\ntarget: alice@rig\ninterval_seconds: 60\ncontext:\n target:\n session: alice@rig\n pools:\n path: /nonexistent-pool-dir-for-test\n",
targetSession: "alice@rig",
intervalSeconds: 60,
registeredBySession: "ops@kernel",
});
const result = await engine.evaluate(job);
expect(result.outcome.action).toBe("skip");
if (result.outcome.action !== "skip") return;
expect(result.outcome.reason).toBe("no_actionable_artifacts");
expect(deliveryCalls).toEqual([]);
expect(log.countForJob(job.jobId)).toBe(1);
expect(log.listForJob(job.jobId)[0]?.outcome).toBe("skipped");
expect(captured.some((e) => e.type === "watchdog.evaluation_skipped")).toBe(true);
const after = jobsRepo.getByIdOrThrow(job.jobId);
expect(after.lastEvaluationAt).not.toBeNull();
expect(after.lastFireAt).toBeNull();
const tmp = join(tmpdir(), `wd-eng-empty-${Date.now()}-${Math.random()}`);
mkdirSync(tmp, { recursive: true });
try {
const job = jobsRepo.register({
policy: "artifact-pool-ready",
specYaml:
`policy: artifact-pool-ready\ntarget:\n session: alice@rig\ninterval_seconds: 60\ncontext:\n pools:\n - path: ${tmp}\n include_statuses: [ready]\n`,
targetSession: "alice@rig",
intervalSeconds: 60,
registeredBySession: "ops@kernel",
});
const result = await engine.evaluate(job);
expect(result.outcome.action).toBe("skip");
if (result.outcome.action !== "skip") return;
expect(result.outcome.reason).toBe("no_actionable_artifacts");
expect(result.meaningful).toBe(false);
expect(deliveryCalls).toEqual([]);
expect(log.countForJob(job.jobId)).toBe(0);
expect(captured.some((e) => e.type === "watchdog.evaluation_skipped")).toBe(false);
const after = jobsRepo.getByIdOrThrow(job.jobId);
expect(after.lastEvaluationAt).not.toBeNull();
expect(after.lastFireAt).toBeNull();
expect(after.actionable).toBe(false);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("delivery failure is recorded as sent outcome with delivery_status=failed", async () => {
it("delivery failure recorded as sent with delivery_status=failed (still meaningful)", async () => {
const failingDeliver: DeliveryFn = async () => ({ status: "failed", error: "transport denied" });
const engine = makeEngine(failingDeliver);
const engine = makeEngine({ deliver: failingDeliver });
const job = jobsRepo.register({
policy: "periodic-reminder",
specYaml:
"policy: periodic-reminder\ntarget: alice@rig\ninterval_seconds: 60\ncontext:\n target:\n session: alice@rig\n message: ping\n",
"policy: periodic-reminder\ntarget:\n session: alice@rig\ninterval_seconds: 60\nmessage: ping\n",
targetSession: "alice@rig",
intervalSeconds: 60,
registeredBySession: "ops@kernel",
@@ -115,17 +128,16 @@ describe("WatchdogPolicyEngine (PL-004 Phase C)", () => {
expect(list[0]?.deliveryStatus).toBe("failed");
});
it("unknown policy at evaluate-time marks job terminal + emits evaluation_terminal", async () => {
it("unknown policy at evaluate-time marks job terminal + records + emits evaluation_terminal", async () => {
const engine = makeEngine();
const job = jobsRepo.register({
policy: "periodic-reminder",
specYaml: "policy: periodic-reminder\ncontext:\n target:\n session: a@rig\n message: x\n",
specYaml:
"policy: periodic-reminder\ntarget:\n session: a@rig\nmessage: x\n",
targetSession: "a@rig",
intervalSeconds: 60,
registeredBySession: "ops@kernel",
});
// Hand-craft a job carrying an unknown policy name. The engine should
// treat this defensively: terminal + history record + event.
const synthetic = { ...job, policy: "totally-bogus" as never };
const result = await engine.evaluate(synthetic);
expect(result.outcome.action).toBe("terminal");
@@ -134,7 +146,7 @@ describe("WatchdogPolicyEngine (PL-004 Phase C)", () => {
expect(captured.some((e) => e.type === "watchdog.evaluation_terminal")).toBe(true);
});
it("policy registry resolves the three v1 policies", () => {
it("policy registry resolves the three v1 policies (workflow-keepalive absent)", () => {
const engine = makeEngine();
expect(engine.resolvePolicy("periodic-reminder")?.name).toBe("periodic-reminder");
expect(engine.resolvePolicy("artifact-pool-ready")?.name).toBe("artifact-pool-ready");
@@ -142,13 +154,13 @@ describe("WatchdogPolicyEngine (PL-004 Phase C)", () => {
expect(engine.resolvePolicy("workflow-keepalive")).toBeUndefined();
});
it("default YAML parser extracts nested context block", async () => {
it("default spec parser extracts top-level target + context + message", async () => {
const engine = makeEngine();
const job = jobsRepo.register({
policy: "periodic-reminder",
specYaml:
"policy: periodic-reminder\ntarget: a@rig\ninterval_seconds: 60\ncontext:\n target:\n session: parsed-session@rig\n message: parsed-message\n",
targetSession: "a@rig",
"policy: periodic-reminder\ntarget:\n session: parsed-session@rig\nmessage: parsed-message\ninterval_seconds: 60\n",
targetSession: "registered-fallback@rig",
intervalSeconds: 60,
registeredBySession: "ops@kernel",
});
@@ -157,4 +169,130 @@ describe("WatchdogPolicyEngine (PL-004 Phase C)", () => {
{ targetSession: "parsed-session@rig", message: "parsed-message" },
]);
});
it("falls back to registered targetSession when spec lacks top-level target", async () => {
const engine = makeEngine();
const job = jobsRepo.register({
policy: "periodic-reminder",
// No top-level target. Engine should synthesize {session: targetSession}.
specYaml: "policy: periodic-reminder\nmessage: ping\ninterval_seconds: 60\n",
targetSession: "fallback@rig",
intervalSeconds: 60,
registeredBySession: "ops@kernel",
});
await engine.evaluate(job);
expect(deliveryCalls).toEqual([{ targetSession: "fallback@rig", message: "ping" }]);
});
// R1 fix (guard blocker 1): port the POC active-wake regression.
// POC source: tests/watchdog-active-wake-interval.test.sh
it("active-wake throttle: re-delivery suppressed during wake window; fires when window elapses", async () => {
const tmp = join(tmpdir(), `wd-actwake-${Date.now()}-${Math.random()}`);
mkdirSync(tmp, { recursive: true });
try {
const job = jobsRepo.register({
policy: "artifact-pool-ready",
specYaml:
`policy: artifact-pool-ready\ntarget:\n session: loop-head@example\ninterval_seconds: 30\nscan_interval_seconds: 30\nactive_wake_interval_seconds: 600\ncontext:\n pools:\n - path: ${tmp}\n include_statuses: [ready]\n`,
targetSession: "loop-head@example",
intervalSeconds: 30,
scanIntervalSeconds: 30,
activeWakeIntervalSeconds: 600,
registeredBySession: "ops@kernel",
});
// Tick 1: empty pool → quiet skip(no_actionable_artifacts), actionable=false.
let nowMs = 0;
let engine = makeEngine({ now: () => new Date(nowMs) });
let r = await engine.evaluate(jobsRepo.getByIdOrThrow(job.jobId));
expect(r.outcome).toEqual({ action: "skip", reason: "no_actionable_artifacts" });
expect(deliveryCalls.length).toBe(0);
// Make pool actionable.
writeFileSync(join(tmp, "ready.md"), "---\nstatus: ready\n---\n");
// Tick 2: newly actionable → fires (no throttle on transition).
nowMs = 30_000;
engine = makeEngine({ now: () => new Date(nowMs) });
r = await engine.evaluate(jobsRepo.getByIdOrThrow(job.jobId));
expect(r.outcome.action).toBe("send");
expect(deliveryCalls.length).toBe(1);
const afterFire = jobsRepo.getByIdOrThrow(job.jobId);
expect(afterFire.actionable).toBe(true);
expect(afterFire.lastFireAt).toBe("1970-01-01T00:00:30.000Z");
// Tick 3 at +30s after fire (60s mark): pool still actionable but
// wake window (600s) not elapsed → quiet skip(active_wake_not_due).
nowMs = 60_000;
engine = makeEngine({ now: () => new Date(nowMs) });
r = await engine.evaluate(jobsRepo.getByIdOrThrow(job.jobId));
expect(r.outcome).toEqual({ action: "skip", reason: "active_wake_not_due" });
expect(deliveryCalls.length).toBe(1);
// last_fire_at preserved.
expect(jobsRepo.getByIdOrThrow(job.jobId).lastFireAt).toBe("1970-01-01T00:00:30.000Z");
// actionable still true.
expect(jobsRepo.getByIdOrThrow(job.jobId).actionable).toBe(true);
// Tick 4 at 630_000ms (>= last_fire + 600_000ms): wake window
// elapsed → fires again.
nowMs = 630_000;
engine = makeEngine({ now: () => new Date(nowMs) });
r = await engine.evaluate(jobsRepo.getByIdOrThrow(job.jobId));
expect(r.outcome.action).toBe("send");
expect(deliveryCalls.length).toBe(2);
const after2ndFire = jobsRepo.getByIdOrThrow(job.jobId);
expect(after2ndFire.lastFireAt).toBe("1970-01-01T00:10:30.000Z");
// Tick 5: pool empties → quiet skip resets actionable.
rmSync(join(tmp, "ready.md"));
nowMs = 660_000;
engine = makeEngine({ now: () => new Date(nowMs) });
r = await engine.evaluate(jobsRepo.getByIdOrThrow(job.jobId));
expect(r.outcome).toEqual({ action: "skip", reason: "no_actionable_artifacts" });
expect(jobsRepo.getByIdOrThrow(job.jobId).actionable).toBe(false);
// Tick 6: pool actionable again → fires immediately (newly actionable).
writeFileSync(join(tmp, "ready.md"), "---\nstatus: ready\n---\n");
nowMs = 690_000;
engine = makeEngine({ now: () => new Date(nowMs) });
r = await engine.evaluate(jobsRepo.getByIdOrThrow(job.jobId));
expect(r.outcome.action).toBe("send");
expect(deliveryCalls.length).toBe(3);
// History reflects only the 3 meaningful events.
expect(log.countForJob(job.jobId)).toBe(3);
const entries = log.listForJob(job.jobId);
// All sent (no quiet skip rows).
for (const e of entries) expect(e.outcome).toBe("sent");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("active-wake throttle does NOT apply when active_wake_interval_seconds is null (every fire goes through)", async () => {
const tmp = join(tmpdir(), `wd-actwake-null-${Date.now()}-${Math.random()}`);
mkdirSync(tmp, { recursive: true });
writeFileSync(join(tmp, "ready.md"), "---\nstatus: ready\n---\n");
try {
const job = jobsRepo.register({
policy: "artifact-pool-ready",
specYaml:
`policy: artifact-pool-ready\ntarget:\n session: x@rig\ninterval_seconds: 30\ncontext:\n pools:\n - path: ${tmp}\n include_statuses: [ready]\n`,
targetSession: "x@rig",
intervalSeconds: 30,
registeredBySession: "ops@kernel",
});
let nowMs = 0;
let engine = makeEngine({ now: () => new Date(nowMs) });
await engine.evaluate(jobsRepo.getByIdOrThrow(job.jobId));
expect(deliveryCalls.length).toBe(1);
nowMs = 1000;
engine = makeEngine({ now: () => new Date(nowMs) });
await engine.evaluate(jobsRepo.getByIdOrThrow(job.jobId));
// Without wake throttle, every send fires.
expect(deliveryCalls.length).toBe(2);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
@@ -153,16 +153,17 @@ describe("WatchdogScheduler (PL-004 Phase C)", () => {
});
it("policy evaluation errors are caught and tick continues for siblings", async () => {
// Job 1 has no message anywhere — periodic-reminder throws policy_spec_invalid.
jobsRepo.register({
policy: "periodic-reminder",
specYaml: "context:\n message: only-message-no-target\n",
specYaml: "policy: periodic-reminder\ntarget:\n session: a@rig\n",
targetSession: "a@rig",
intervalSeconds: 30,
registeredBySession: "ops@kernel",
});
const ok = jobsRepo.register({
policy: "periodic-reminder",
specYaml: "context:\n target:\n session: ok@rig\n message: ok\n",
specYaml: "policy: periodic-reminder\ntarget:\n session: ok@rig\nmessage: ok\n",
targetSession: "ok@rig",
intervalSeconds: 30,
registeredBySession: "ops@kernel",