@hilbras/omninode 2.0.0-alpha.3 → 2.0.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -6,7 +6,7 @@ import { realpathSync } from "fs";
6
6
  import { Command } from "commander";
7
7
 
8
8
  // src/version.ts
9
- var OMNINODE_VERSION = "2.0.0-alpha.3";
9
+ var OMNINODE_VERSION = "2.0.0-alpha.5";
10
10
 
11
11
  // src/errors/index.ts
12
12
  var OmniNodeError = class extends Error {
@@ -83,6 +83,11 @@ var agentConfigSchema = z.object({
83
83
  input_mode: z.enum(["stdin", "arg", "protocol"]).default("stdin"),
84
84
  timeout_ms: z.number().int().positive().optional(),
85
85
  inherit_env: z.boolean().optional(),
86
+ env_policy: z.enum(["inherit", "allowlist", "denylist", "explicit"]).optional(),
87
+ env_allowlist: z.array(z.string()).optional(),
88
+ env_denylist: z.array(z.string()).optional(),
89
+ max_output_bytes: z.number().int().positive().optional(),
90
+ allow_external_cwd: z.boolean().optional(),
86
91
  metadata: z.record(z.unknown()).optional()
87
92
  }).strict();
88
93
  var roleConfigSchema = z.object({
@@ -257,6 +262,11 @@ function toAgentConfig(raw) {
257
262
  inputMode: raw.input_mode,
258
263
  timeoutMs: raw.timeout_ms,
259
264
  inheritEnv: raw.inherit_env,
265
+ envPolicy: raw.env_policy,
266
+ envAllowlist: raw.env_allowlist,
267
+ envDenylist: raw.env_denylist,
268
+ maxOutputBytes: raw.max_output_bytes,
269
+ allowExternalCwd: raw.allow_external_cwd,
260
270
  metadata: raw.metadata
261
271
  };
262
272
  }
@@ -1214,20 +1224,94 @@ var ProtocolSession = class {
1214
1224
  sawV2 = false;
1215
1225
  };
1216
1226
 
1217
- // src/agents/process/index.ts
1218
- var DEFAULT_TIMEOUT_MS2 = 6e5;
1219
- var KILL_GRACE_MS = 5e3;
1227
+ // src/agents/cwd.ts
1228
+ import { statSync } from "fs";
1229
+ import { isAbsolute, relative, resolve } from "path";
1230
+ function validateWorkingDirectory(cwd, options = {}) {
1231
+ if (!cwd) return void 0;
1232
+ const normalized = isAbsolute(cwd) ? resolve(cwd) : resolve(process.cwd(), cwd);
1233
+ let stats;
1234
+ try {
1235
+ stats = statSync(normalized);
1236
+ } catch (error) {
1237
+ throw new AgentError(
1238
+ "AGENT_NOT_FOUND",
1239
+ `Working directory "${normalized}" does not exist or is not accessible.`,
1240
+ { cause: error, details: { cwd: normalized } }
1241
+ );
1242
+ }
1243
+ if (!stats.isDirectory()) {
1244
+ throw new AgentError("AGENT_NOT_FOUND", `Working directory "${normalized}" is not a directory.`, {
1245
+ details: { cwd: normalized }
1246
+ });
1247
+ }
1248
+ try {
1249
+ statSync(resolve(normalized, "."));
1250
+ } catch (error) {
1251
+ throw new AgentError(
1252
+ "AGENT_NOT_FOUND",
1253
+ `Working directory "${normalized}" is not accessible.`,
1254
+ { cause: error }
1255
+ );
1256
+ }
1257
+ const projectRoot = resolve(options.projectRoot ?? process.cwd());
1258
+ const rel = relative(projectRoot, normalized);
1259
+ const outsideProject = rel.startsWith("..") || isAbsolute(rel);
1260
+ if (outsideProject && options.allowExternal !== true) {
1261
+ throw new AgentError(
1262
+ "AGENT_NOT_FOUND",
1263
+ `Working directory "${normalized}" is outside the project root "${projectRoot}". Set allow_external_cwd: true on the agent to permit this.`,
1264
+ { details: { cwd: normalized, projectRoot } }
1265
+ );
1266
+ }
1267
+ return { cwd: normalized, outsideProject };
1268
+ }
1269
+
1270
+ // src/agents/env.ts
1271
+ var BASELINE_KEYS = ["PATH", "HOME"];
1272
+ function resolveEnvPolicy(config) {
1273
+ if (config.envPolicy !== void 0) return config.envPolicy;
1274
+ if (config.inheritEnv === false) return "explicit";
1275
+ return "inherit";
1276
+ }
1220
1277
  function buildChildEnv(config) {
1221
1278
  const configured = config.env ?? {};
1222
- if (config.inheritEnv !== false) {
1279
+ const policy = resolveEnvPolicy(config);
1280
+ if (policy === "inherit") {
1223
1281
  return { ...process.env, ...configured };
1224
1282
  }
1225
- return {
1226
- PATH: process.env.PATH ?? "",
1227
- HOME: process.env.HOME ?? "",
1228
- ...configured
1229
- };
1283
+ const base = {};
1284
+ for (const key of BASELINE_KEYS) {
1285
+ const value = process.env[key];
1286
+ if (value !== void 0) base[key] = value;
1287
+ }
1288
+ switch (policy) {
1289
+ case "explicit":
1290
+ return { ...base, ...configured };
1291
+ case "allowlist": {
1292
+ const allowed = { ...base };
1293
+ for (const key of config.envAllowlist ?? []) {
1294
+ const value = process.env[key];
1295
+ if (value !== void 0) allowed[key] = value;
1296
+ }
1297
+ return { ...allowed, ...configured };
1298
+ }
1299
+ case "denylist": {
1300
+ const denied = new Set((config.envDenylist ?? []).map((k) => k.toUpperCase()));
1301
+ const filtered = {};
1302
+ for (const [key, value] of Object.entries(process.env)) {
1303
+ if (denied.has(key.toUpperCase())) continue;
1304
+ filtered[key] = value;
1305
+ }
1306
+ return { ...filtered, ...configured };
1307
+ }
1308
+ }
1230
1309
  }
1310
+
1311
+ // src/agents/process/index.ts
1312
+ var DEFAULT_TIMEOUT_MS2 = 6e5;
1313
+ var KILL_GRACE_MS = 5e3;
1314
+ var DEFAULT_MAX_OUTPUT_BYTES = 5 * 1024 * 1024;
1231
1315
  function composeTaskText(input) {
1232
1316
  const sections = [`# Objective
1233
1317
  ${input.objective}`];
@@ -1289,40 +1373,73 @@ var ProcessAgent = class {
1289
1373
  }
1290
1374
  const mode = this.config.inputMode ?? "stdin";
1291
1375
  const timeoutMs = this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
1292
- return new Promise((resolve, reject) => {
1376
+ return new Promise((resolve2, reject) => {
1377
+ let cwd;
1378
+ try {
1379
+ cwd = validateWorkingDirectory(this.config.cwd, {
1380
+ allowExternal: this.config.allowExternalCwd
1381
+ })?.cwd;
1382
+ } catch (error) {
1383
+ reject(error);
1384
+ return;
1385
+ }
1293
1386
  const child = spawn(
1294
1387
  command,
1295
1388
  [...this.config.args ?? [], ...mode === "arg" ? [composeTaskText(input)] : []],
1296
1389
  {
1297
- cwd: this.config.cwd,
1390
+ ...cwd !== void 0 ? { cwd } : {},
1298
1391
  env: buildChildEnv(this.config),
1299
- stdio: ["pipe", "pipe", "pipe"]
1392
+ stdio: ["pipe", "pipe", "pipe"],
1393
+ // Own process group so the whole tree can be terminated together
1394
+ // (roadmap §8: no orphaned children).
1395
+ ...process.platform === "win32" ? {} : { detached: true }
1300
1396
  }
1301
1397
  );
1302
1398
  this.running.set(input.taskId, child);
1399
+ GLOBAL_RUNNING.add(child);
1400
+ installSignalForwarding();
1303
1401
  let stdout = "";
1304
1402
  let stderr = "";
1305
1403
  let timedOut = false;
1404
+ let outputLimitExceeded = false;
1306
1405
  let settled = false;
1406
+ const maxOutputBytes = this.config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
1307
1407
  const timer = setTimeout(() => {
1308
1408
  timedOut = true;
1309
- this.log.warn(`Task ${input.taskId} exceeded ${timeoutMs}ms \u2014 killing process.`);
1310
- child.kill("SIGTERM");
1311
- setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS).unref();
1409
+ this.log.warn(`Task ${input.taskId} exceeded ${timeoutMs}ms \u2014 killing process tree.`);
1410
+ killTree(child, "SIGTERM");
1411
+ setTimeout(() => killTree(child, "SIGKILL"), KILL_GRACE_MS).unref();
1312
1412
  }, timeoutMs);
1313
1413
  const settle = (finish) => {
1314
1414
  if (settled) return;
1315
1415
  settled = true;
1316
1416
  clearTimeout(timer);
1317
1417
  this.running.delete(input.taskId);
1418
+ GLOBAL_RUNNING.delete(child);
1318
1419
  finish();
1319
1420
  };
1320
- child.stdout?.on("data", (chunk) => {
1321
- stdout += chunk.toString();
1322
- });
1323
- child.stderr?.on("data", (chunk) => {
1324
- stderr += chunk.toString();
1325
- });
1421
+ const collect = (into, chunk) => {
1422
+ if (outputLimitExceeded) return;
1423
+ const text = sanitizeChunk(chunk);
1424
+ if (into === "stdout") {
1425
+ if (stdout.length + text.length > maxOutputBytes) {
1426
+ stdout = (stdout + text).slice(0, maxOutputBytes);
1427
+ outputLimitExceeded = true;
1428
+ this.log.warn(`Agent "${this.config.name}" exceeded the ${maxOutputBytes} byte output limit \u2014 killing it.`);
1429
+ killTree(child, "SIGKILL");
1430
+ return;
1431
+ }
1432
+ stdout += text;
1433
+ } else {
1434
+ if (stderr.length + text.length > maxOutputBytes) {
1435
+ stderr = (stderr + text).slice(0, maxOutputBytes);
1436
+ return;
1437
+ }
1438
+ stderr += text;
1439
+ }
1440
+ };
1441
+ child.stdout?.on("data", (chunk) => collect("stdout", chunk));
1442
+ child.stderr?.on("data", (chunk) => collect("stderr", chunk));
1326
1443
  child.stdin?.on("error", () => {
1327
1444
  });
1328
1445
  if (mode !== "arg") {
@@ -1376,7 +1493,7 @@ var ProcessAgent = class {
1376
1493
  const logs = stderr.trim().length > 0 ? stderr.split("\n") : void 0;
1377
1494
  if (mode === "protocol") {
1378
1495
  try {
1379
- resolve(
1496
+ resolve2(
1380
1497
  interpretProtocolRun({
1381
1498
  input,
1382
1499
  agentName: this.config.name,
@@ -1391,14 +1508,14 @@ var ProcessAgent = class {
1391
1508
  return;
1392
1509
  }
1393
1510
  const trimmed = stdout.trim();
1394
- resolve({
1511
+ resolve2({
1395
1512
  taskId: input.taskId,
1396
- status: code === 0 ? "completed" : "failed",
1513
+ status: code === 0 && !outputLimitExceeded ? "completed" : "failed",
1397
1514
  summary: trimmed.length > 0 ? firstLine(trimmed) : void 0,
1398
1515
  rawOutput: trimmed.length > 0 ? trimmed : void 0,
1399
1516
  exitCode: code ?? void 0,
1400
1517
  logs,
1401
- error: code === 0 ? void 0 : `Process exited with code ${code ?? "unknown"}${signal ? ` (signal: ${signal})` : ""}`
1518
+ ...outputLimitExceeded ? { error: `Agent exceeded the ${maxOutputBytes} byte output limit and was terminated.` } : code === 0 ? {} : { error: describeExit(code, signal) }
1402
1519
  });
1403
1520
  });
1404
1521
  });
@@ -1407,7 +1524,7 @@ var ProcessAgent = class {
1407
1524
  /** Ask the agent to stop current work (SIGTERM). */
1408
1525
  async cancel(taskId) {
1409
1526
  const child = this.running.get(taskId);
1410
- if (child) child.kill("SIGTERM");
1527
+ if (child) killTree(child, "SIGTERM");
1411
1528
  }
1412
1529
  runningTaskIds() {
1413
1530
  return [...this.running.keys()];
@@ -1482,6 +1599,43 @@ function interpretProtocolRun({
1482
1599
  ...exitCode !== 0 ? { error: `Process exited with code ${exitCode ?? "unknown"}` } : {}
1483
1600
  };
1484
1601
  }
1602
+ var GLOBAL_RUNNING = /* @__PURE__ */ new Set();
1603
+ var signalForwardingInstalled = false;
1604
+ function installSignalForwarding() {
1605
+ if (signalForwardingInstalled) return;
1606
+ signalForwardingInstalled = true;
1607
+ for (const signal of ["SIGINT", "SIGTERM"]) {
1608
+ process.on(signal, () => {
1609
+ for (const child of GLOBAL_RUNNING) {
1610
+ killTree(child, "SIGTERM");
1611
+ }
1612
+ });
1613
+ }
1614
+ }
1615
+ function killTree(child, signal) {
1616
+ if (child.pid === void 0) return;
1617
+ if (process.platform !== "win32") {
1618
+ try {
1619
+ process.kill(-child.pid, signal);
1620
+ return;
1621
+ } catch {
1622
+ }
1623
+ }
1624
+ try {
1625
+ child.kill(signal);
1626
+ } catch {
1627
+ }
1628
+ }
1629
+ function describeExit(code, signal) {
1630
+ if (signal !== null) {
1631
+ return signal === "SIGKILL" ? "Process was killed (SIGKILL) \u2014 usually an external kill or OOM." : `Process terminated by signal ${signal}.`;
1632
+ }
1633
+ if (code === null) return "Process terminated without an exit code.";
1634
+ return `Process exited with code ${code}.`;
1635
+ }
1636
+ function sanitizeChunk(chunk) {
1637
+ return [...chunk.toString("utf8")].filter((char) => char.charCodeAt(0) !== 0).join("");
1638
+ }
1485
1639
  function normalizeReport(payload, taskId, agentName, index) {
1486
1640
  if (payload === null || typeof payload !== "object") return void 0;
1487
1641
  const raw = payload;
@@ -1501,6 +1655,40 @@ function firstLine(text, maxLength = 200) {
1501
1655
  return line.length > maxLength ? `${line.slice(0, maxLength)}\u2026` : line;
1502
1656
  }
1503
1657
 
1658
+ // src/agents/adapters.ts
1659
+ var AgentAdapterRegistry = class {
1660
+ adapters = /* @__PURE__ */ new Map();
1661
+ register(adapter) {
1662
+ this.adapters.set(adapter.integration, adapter);
1663
+ }
1664
+ get(integration) {
1665
+ return this.adapters.get(integration);
1666
+ }
1667
+ list() {
1668
+ return [...this.adapters.values()];
1669
+ }
1670
+ create(config) {
1671
+ const adapter = this.get(config.integration);
1672
+ if (!adapter) {
1673
+ throw new AgentError(
1674
+ "AGENT_NOT_FOUND",
1675
+ `No agent adapter registered for integration "${config.integration}". Registered: ${[...this.adapters.keys()].join(", ") || "(none)"}.`
1676
+ );
1677
+ }
1678
+ return adapter.create(config);
1679
+ }
1680
+ };
1681
+ var ProcessAgentAdapter = class {
1682
+ constructor(factory) {
1683
+ this.factory = factory;
1684
+ }
1685
+ factory;
1686
+ integration = "process";
1687
+ create(config) {
1688
+ return this.factory(config);
1689
+ }
1690
+ };
1691
+
1504
1692
  // src/agents/registry.ts
1505
1693
  var AgentRegistry = class {
1506
1694
  agents = /* @__PURE__ */ new Map();
@@ -1516,14 +1704,15 @@ var AgentRegistry = class {
1516
1704
  };
1517
1705
 
1518
1706
  // src/agents/factory.ts
1519
- function createAgent(config) {
1520
- if (config.integration === "process") {
1521
- return new ProcessAgent(config);
1522
- }
1707
+ var defaultAgentAdapters = new AgentAdapterRegistry();
1708
+ defaultAgentAdapters.register(new ProcessAgentAdapter((config) => new ProcessAgent(config)));
1709
+ function createAgent(config, adapters = defaultAgentAdapters) {
1710
+ const registered = adapters.get(config.integration);
1711
+ if (registered) return registered.create(config);
1523
1712
  if (config.integration === "native") {
1524
1713
  throw new OmniNodeError(
1525
1714
  "NOT_IMPLEMENTED",
1526
- `Native agent adapters are planned for a later phase. Use input over stdin/stdout (type "cli") for "${config.name}" for now.`
1715
+ `No native adapter is registered for "${config.name}". Register an IAgentAdapter (createAgent(config, adapters)) or use input over stdin/stdout (type "cli").`
1527
1716
  );
1528
1717
  }
1529
1718
  throw new OmniNodeError("NOT_IMPLEMENTED", `Agent integration "${config.integration}" has no adapter yet.`);
@@ -1566,7 +1755,7 @@ function registerAgentCommands(program) {
1566
1755
  "--input-mode <mode>",
1567
1756
  "How the task is delivered: stdin (prompt on stdin), arg (prompt as last argument) or protocol (\xA721 JSON-lines task protocol).",
1568
1757
  "stdin"
1569
- ).option(
1758
+ ).option("--env-policy <policy>", "Environment policy: inherit, allowlist, denylist or explicit.").option("--allow-env <name>", "Variable to forward under the allowlist policy (repeatable).", collectArgs).option("--deny-env <name>", "Variable to remove under the denylist policy (repeatable).", collectArgs).option("--max-output-bytes <n>", "Per-stream output budget in bytes (default 5 MiB).", (value) => Number(value)).option("--allow-external-cwd", "Allow a working directory outside the project root.").option(
1570
1759
  "--timeout-ms <ms>",
1571
1760
  "Per-task timeout in milliseconds.",
1572
1761
  (value) => Number(value)
@@ -1581,7 +1770,12 @@ async function addAgent(options) {
1581
1770
  ...options.arg !== void 0 && options.arg.length > 0 ? { args: options.arg } : {},
1582
1771
  ...options.cwd !== void 0 ? { cwd: options.cwd } : {},
1583
1772
  input_mode: options.inputMode,
1584
- ...options.timeoutMs !== void 0 ? { timeout_ms: options.timeoutMs } : {}
1773
+ ...options.timeoutMs !== void 0 ? { timeout_ms: options.timeoutMs } : {},
1774
+ ...options.envPolicy !== void 0 ? { env_policy: options.envPolicy } : {},
1775
+ ...options.allowEnv !== void 0 && options.allowEnv.length > 0 ? { env_allowlist: options.allowEnv } : {},
1776
+ ...options.denyEnv !== void 0 && options.denyEnv.length > 0 ? { env_denylist: options.denyEnv } : {},
1777
+ ...options.maxOutputBytes !== void 0 ? { max_output_bytes: options.maxOutputBytes } : {},
1778
+ ...options.allowExternalCwd !== void 0 ? { allow_external_cwd: options.allowExternalCwd } : {}
1585
1779
  });
1586
1780
  if (!candidate.success) {
1587
1781
  const issues = candidate.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
@@ -1874,6 +2068,11 @@ var TaskEngine = class {
1874
2068
  if (this.options.memory) await this.options.memory.recordTaskOutcome(current);
1875
2069
  return current;
1876
2070
  }
2071
+ const currentState = await this.options.store.get(current.id);
2072
+ if (currentState?.status === "cancelled") {
2073
+ current = currentState;
2074
+ break;
2075
+ }
1877
2076
  const result = {
1878
2077
  ...output.summary !== void 0 ? { summary: output.summary } : {},
1879
2078
  ...output.reports !== void 0 && output.reports.length > 0 ? { reports: output.reports } : {},
@@ -1985,7 +2184,7 @@ function classifyThrown(error) {
1985
2184
  return "failed";
1986
2185
  }
1987
2186
  function sleep(ms) {
1988
- return new Promise((resolve) => setTimeout(resolve, ms));
2187
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1989
2188
  }
1990
2189
 
1991
2190
  // src/audit/index.ts
@@ -2999,9 +3198,23 @@ var PipelineEngine = class {
2999
3198
  }
3000
3199
  options;
3001
3200
  log;
3201
+ /** Runs executing in THIS process — cancellation can terminate them directly. */
3202
+ activeRuns = /* @__PURE__ */ new Map();
3002
3203
  get store() {
3003
3204
  return this.options.store;
3004
3205
  }
3206
+ /**
3207
+ * Saves the run while preserving fields that may be set by another process
3208
+ * (e.g. a cancellation request) — an in-memory snapshot must never clobber
3209
+ * externally requested cancellation (§9.2).
3210
+ */
3211
+ async persist(run2) {
3212
+ const current = await this.options.store.get(run2.id);
3213
+ await this.options.store.save({
3214
+ ...run2,
3215
+ ...current?.cancellationRequested === true ? { cancellationRequested: true, cancellationRequestedAt: current.cancellationRequestedAt } : {}
3216
+ });
3217
+ }
3005
3218
  /** Structural + semantic validation (§27 Phase 5 — pipeline parser). */
3006
3219
  validate(def) {
3007
3220
  const ids = /* @__PURE__ */ new Set();
@@ -3101,18 +3314,44 @@ var PipelineEngine = class {
3101
3314
  );
3102
3315
  }
3103
3316
  }
3317
+ if (step.model) {
3318
+ const separator = step.model.indexOf(":");
3319
+ if (separator <= 0 || separator === step.model.length - 1) {
3320
+ throw new PipelineError(
3321
+ "PIPELINE_INVALID",
3322
+ `Step "${step.id}" has an invalid model reference "${step.model}" \u2014 use "provider:model-id".`
3323
+ );
3324
+ }
3325
+ const providerName = step.model.slice(0, separator);
3326
+ if (!this.options.providers.some((p) => p.name === providerName)) {
3327
+ throw new PipelineError(
3328
+ "PIPELINE_INVALID",
3329
+ `Step "${step.id}" uses model "${step.model}" but provider "${providerName}" is not configured.`
3330
+ );
3331
+ }
3332
+ }
3104
3333
  }
3105
3334
  const now = (/* @__PURE__ */ new Date()).toISOString();
3106
3335
  const run2 = {
3107
3336
  id: `run-${Date.now().toString(36)}-${randomBytes5(3).toString("hex")}`,
3108
3337
  pipelineId: def.id,
3338
+ attempt: 1,
3109
3339
  objective,
3110
3340
  status: "running",
3111
3341
  startedAt: now,
3112
3342
  stepRuns: []
3113
3343
  };
3114
3344
  await this.options.store.save(run2);
3115
- this.log.info(`Running pipeline ${def.id} as ${run2.id}.`);
3345
+ const liveTaskIds = /* @__PURE__ */ new Set();
3346
+ this.activeRuns.set(run2.id, {
3347
+ taskIds: liveTaskIds,
3348
+ cancelTasks: async () => {
3349
+ for (const taskId of liveTaskIds) {
3350
+ await this.options.tasks.cancel(taskId).catch(() => void 0);
3351
+ }
3352
+ }
3353
+ });
3354
+ this.log.info(`Running pipeline ${def.id} as ${run2.id} (attempt ${run2.attempt}).`);
3116
3355
  await this.options.audit?.record({
3117
3356
  at: (/* @__PURE__ */ new Date()).toISOString(),
3118
3357
  action: "pipeline.run.started",
@@ -3124,7 +3363,22 @@ var PipelineEngine = class {
3124
3363
  const pending = new Set(def.steps.map((s) => s.id));
3125
3364
  const contextParts = [];
3126
3365
  const allReports = [];
3366
+ let cancelled = false;
3127
3367
  while (pending.size > 0) {
3368
+ const persisted = await this.options.store.get(run2.id);
3369
+ if (persisted?.cancellationRequested === true) {
3370
+ cancelled = true;
3371
+ run2.cancellationRequested = true;
3372
+ run2.cancellationRequestedAt = persisted.cancellationRequestedAt;
3373
+ await this.activeRuns.get(run2.id)?.cancelTasks();
3374
+ for (const step of def.steps.filter((s) => pending.has(s.id))) {
3375
+ const stepRun = { stepId: step.id, status: "cancelled" };
3376
+ run2.stepRuns.push(stepRun);
3377
+ options.onStep?.(stepRun);
3378
+ }
3379
+ pending.clear();
3380
+ break;
3381
+ }
3128
3382
  const ready = def.steps.filter(
3129
3383
  (step) => pending.has(step.id) && (deps.get(step.id) ?? []).every((d) => outcomes.has(d))
3130
3384
  );
@@ -3141,7 +3395,8 @@ var PipelineEngine = class {
3141
3395
  ...role ? { role } : {},
3142
3396
  combinedContext: contextParts.join("\n\n"),
3143
3397
  reports: [...allReports],
3144
- pipelineRunId: run2.id
3398
+ pipelineRunId: run2.id,
3399
+ runId: run2.id
3145
3400
  };
3146
3401
  return this.executeStep(step, ctx);
3147
3402
  })
@@ -3158,6 +3413,8 @@ ${contribution}`);
3158
3413
  }
3159
3414
  const stepRun = {
3160
3415
  stepId: step.id,
3416
+ attempt: outcome.attempt ?? 1,
3417
+ ...outcome.executionIds && outcome.executionIds.length > 0 ? { executionIds: outcome.executionIds } : {},
3161
3418
  status: outcome.status === "skipped" ? "cancelled" : outcome.status === "failed" ? "failed" : outcome.status === "partial" ? "partially_completed" : "completed",
3162
3419
  ...outcome.taskIds.length > 0 ? { taskIds: outcome.taskIds } : {},
3163
3420
  ...outcome.planId !== void 0 ? { planId: outcome.planId } : {},
@@ -3167,12 +3424,12 @@ ${contribution}`);
3167
3424
  options.onStep?.(stepRun);
3168
3425
  });
3169
3426
  run2.reports = [...allReports];
3170
- await this.options.store.save(run2);
3427
+ await this.persist(run2);
3171
3428
  }
3172
3429
  const allOutcomes = [...outcomes.values()];
3173
- const failed = allOutcomes.some((o) => o.status === "failed");
3174
- const partial = !failed && allOutcomes.some((o) => o.status === "partial");
3175
- const finalStatus = failed ? "failed" : partial ? "partial" : "completed";
3430
+ const failed = !cancelled && allOutcomes.some((o) => o.status === "failed");
3431
+ const partial = !cancelled && !failed && allOutcomes.some((o) => o.status === "partial");
3432
+ const finalStatus = cancelled ? "cancelled" : failed ? "failed" : partial ? "partial" : "completed";
3176
3433
  const planId = [...outcomes.values()].find((o) => o.planId !== void 0)?.planId;
3177
3434
  let resultSummary;
3178
3435
  for (let i = def.steps.length - 1; i >= 0; i -= 1) {
@@ -3200,16 +3457,19 @@ ${contribution}`);
3200
3457
  run2.reports = collected;
3201
3458
  }
3202
3459
  }
3460
+ const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
3461
+ this.activeRuns.delete(run2.id);
3203
3462
  const finished = {
3204
3463
  ...run2,
3205
3464
  status: finalStatus,
3206
- finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
3465
+ finishedAt,
3466
+ attempts: [...run2.attempts ?? [], { attempt: run2.attempt, startedAt: run2.startedAt ?? finishedAt, finishedAt, status: finalStatus }],
3207
3467
  ...run2.reports !== void 0 && run2.reports.length > 0 ? { reports: run2.reports } : {},
3208
3468
  ...combinedReportId !== void 0 ? { combinedReportId } : {},
3209
3469
  ...planId !== void 0 ? { planId } : {},
3210
3470
  ...resultSummary !== void 0 ? { resultSummary } : {}
3211
3471
  };
3212
- await this.options.store.save(finished);
3472
+ await this.persist(finished);
3213
3473
  this.log.info(`Pipeline ${def.id} finished: ${finalStatus}.`);
3214
3474
  await this.options.audit?.record({
3215
3475
  at: (/* @__PURE__ */ new Date()).toISOString(),
@@ -3219,6 +3479,23 @@ ${contribution}`);
3219
3479
  });
3220
3480
  return finished;
3221
3481
  }
3482
+ /** First-class cancellation (§9.2): requests cancellation and terminates
3483
+ * tasks that are running in this process immediately. */
3484
+ async cancel(runId) {
3485
+ const run2 = await this.options.store.get(runId);
3486
+ if (!run2) {
3487
+ throw new PipelineError("PIPELINE_NOT_FOUND", `Pipeline run "${runId}" does not exist.`);
3488
+ }
3489
+ await this.options.store.save({
3490
+ ...run2,
3491
+ cancellationRequested: true,
3492
+ cancellationRequestedAt: (/* @__PURE__ */ new Date()).toISOString()
3493
+ });
3494
+ const active = this.activeRuns.get(runId);
3495
+ if (active) {
3496
+ await active.cancelTasks();
3497
+ }
3498
+ }
3222
3499
  async get(runId) {
3223
3500
  return this.options.store.get(runId);
3224
3501
  }
@@ -3242,9 +3519,16 @@ ${contribution}`);
3242
3519
  this.log.warn(`Step "${step.id}" failed \u2014 retry ${attempt}/${retries}.`);
3243
3520
  }
3244
3521
  last = await this.executeStepOnce(step, ctx);
3245
- if (last.status !== "failed") return last;
3522
+ if (last.status !== "failed") return { ...last, attempt: attempt + 1 };
3246
3523
  }
3247
- return last;
3524
+ return { ...last, attempt: retries + 1 };
3525
+ }
3526
+ /** Registers tasks created for a step as live, so cancellation can terminate them (§9.2). */
3527
+ trackLiveTasks(runId, taskIds) {
3528
+ if (!runId) return;
3529
+ const active = this.activeRuns.get(runId);
3530
+ if (!active) return;
3531
+ for (const id of taskIds) active.taskIds.add(id);
3248
3532
  }
3249
3533
  async executeStepOnce(step, ctx) {
3250
3534
  switch (step.kind) {
@@ -3277,7 +3561,9 @@ ${contribution}`);
3277
3561
  })
3278
3562
  )
3279
3563
  );
3564
+ this.trackLiveTasks(ctx.runId, created.map((t) => t.id));
3280
3565
  const results = await Promise.all(created.map((task) => this.options.tasks.run(task.id)));
3566
+ const executionIds = results.map((r) => r.executions?.at(-1)?.executionId).filter((id) => id !== void 0);
3281
3567
  const completedTasks = results.filter((r) => r.status === "completed");
3282
3568
  const failedTasks = results.filter((r) => r.status !== "completed");
3283
3569
  const lines = results.map((result, index) => {
@@ -3300,7 +3586,13 @@ ${contribution}`);
3300
3586
  reports
3301
3587
  };
3302
3588
  }
3303
- return { status: "completed", summary, taskIds: created.map((t) => t.id), reports };
3589
+ return {
3590
+ status: "completed",
3591
+ summary,
3592
+ taskIds: created.map((t) => t.id),
3593
+ ...executionIds.length > 0 ? { executionIds } : {},
3594
+ reports
3595
+ };
3304
3596
  }
3305
3597
  async runSingle(step, ctx, agentName) {
3306
3598
  const task = await this.options.tasks.create({
@@ -3309,6 +3601,7 @@ ${contribution}`);
3309
3601
  ...ctx.role ? { role: ctx.role.id } : {},
3310
3602
  ...ctx.combinedContext.length > 0 ? { context: { background: ctx.combinedContext } } : {}
3311
3603
  });
3604
+ this.trackLiveTasks(ctx.runId, [task.id]);
3312
3605
  const result = await this.options.tasks.run(task.id);
3313
3606
  if (result.status !== "completed") {
3314
3607
  return {
@@ -3559,6 +3852,24 @@ function registerPipelineCommands(program) {
3559
3852
  }
3560
3853
  await runPipelineAction(def.id, objective ?? previous.objective);
3561
3854
  });
3855
+ pipeline.command("cancel <run-id>").description("Request cancellation of a pipeline run (stops scheduling and cancels running tasks).").action(async (runId) => {
3856
+ const config = loadConfig();
3857
+ const engine = buildPipelineEngine(config);
3858
+ await engine.cancel(runId);
3859
+ console.log(`Cancellation requested for run ${runId}.`);
3860
+ console.log("A run executing in another process stops at its next check point.");
3861
+ });
3862
+ pipeline.command("validate <id>").description("Validate a pipeline definition before running it (\xA79.5).").action(async (id) => {
3863
+ const config = loadConfig();
3864
+ const def = findPipeline(config, id);
3865
+ const engine = buildPipelineEngine(config);
3866
+ engine.validate(def);
3867
+ console.log(`Pipeline "${def.id}" is valid (${def.steps.length} step(s)).`);
3868
+ for (const step of def.steps) {
3869
+ const deps = step.dependsOn?.join(", ") ?? "(previous step)";
3870
+ console.log(` ${step.id} kind=${step.kind} depends_on=${deps}`);
3871
+ }
3872
+ });
3562
3873
  pipeline.command("runs <id>").description("List recorded runs of a pipeline.").action(async (id) => {
3563
3874
  const config = loadConfig();
3564
3875
  const engine = buildPipelineEngine(config);
@@ -3568,7 +3879,8 @@ function registerPipelineCommands(program) {
3568
3879
  return;
3569
3880
  }
3570
3881
  for (const run2 of runs) {
3571
- console.log(`${run2.id} ${run2.status.padEnd(10)} ${run2.startedAt ?? "?"}`);
3882
+ const note = isInterrupted(run2) ? " (interrupted \u2014 process gone before finish)" : "";
3883
+ console.log(`${run2.id} ${run2.status.padEnd(10)} ${run2.startedAt ?? "?"}${note}`);
3572
3884
  }
3573
3885
  });
3574
3886
  pipeline.command("status <run-id>").description("Show the recorded state of a pipeline run.").action(async (runId) => {
@@ -3586,6 +3898,13 @@ function registerPipelineCommands(program) {
3586
3898
  console.log(` status: ${run2.status}`);
3587
3899
  console.log(` started: ${run2.startedAt ?? "?"}`);
3588
3900
  if (run2.finishedAt) console.log(` finished: ${run2.finishedAt}`);
3901
+ console.log(` attempt: ${run2.attempt}`);
3902
+ if (run2.cancellationRequested) {
3903
+ console.log(` cancellation requested at ${run2.cancellationRequestedAt ?? "?"}`);
3904
+ }
3905
+ if (isInterrupted(run2)) {
3906
+ console.log(" note: this run looks interrupted (no finishedAt) \u2014 retry with `pipeline retry`");
3907
+ }
3589
3908
  if (run2.resultSummary) console.log(` result: ${run2.resultSummary}`);
3590
3909
  for (const stepRun of run2.stepRuns) {
3591
3910
  console.log(` [${stepRun.stepId}] ${stepRun.status}`);
@@ -3627,6 +3946,9 @@ async function runPipelineAction(id, objective) {
3627
3946
  function registerRunAlias(program) {
3628
3947
  program.command("run <id> [objective]").description("Alias of `omninode pipeline run` \u2014 execute a configured pipeline end to end.").action(async (id, objective) => runPipelineAction(id, objective));
3629
3948
  }
3949
+ function isInterrupted(run2) {
3950
+ return run2.status === "running" && run2.finishedAt === void 0;
3951
+ }
3630
3952
  function findPipeline(config, id) {
3631
3953
  const def = config.project.pipelines.find((p) => p.id === id);
3632
3954
  if (!def) {