@lchase/yak 0.4.1 → 0.5.0

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.
Files changed (3) hide show
  1. package/README.md +3 -1
  2. package/dist/index.js +136 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -42,7 +42,9 @@ bug) for a guided walkthrough — also live at
42
42
  rather than failing it. `yak pending` lists every run across the repo
43
43
  awaiting a human answer; `yak resume <run-id>` replays and continues;
44
44
  `--interactive` prompts through open gates inline instead of exiting to
45
- resume later.
45
+ resume later. `yak cancel <run-id>` terminates a live run's process tree
46
+ and journals a clean `failed`/`cancelled` terminal state, so any
47
+ out-of-process supervisor has a supported kill.
46
48
  - **Worktree isolation:** `yak run --isolation worktree` runs the whole
47
49
  workflow inside a fresh git worktree; `map` steps can additionally
48
50
  isolate each item into its own sibling worktree.
package/dist/index.js CHANGED
@@ -606,17 +606,17 @@ function checkNeedsSatisfied(steps, producerOf) {
606
606
  function checkNoCycles(steps, producerOf) {
607
607
  const stepsById = new Map(steps.map((s) => [s.id, s]));
608
608
  const state = /* @__PURE__ */ new Map();
609
- function visit(step, path21) {
609
+ function visit(step, path22) {
610
610
  const mark = state.get(step.id);
611
611
  if (mark === "done") return;
612
612
  if (mark === "visiting") {
613
- const cycle = [...path21, step.id].join(" -> ");
613
+ const cycle = [...path22, step.id].join(" -> ");
614
614
  throw new WorkflowValidationError(`cycle detected: ${cycle}`);
615
615
  }
616
616
  state.set(step.id, "visiting");
617
617
  for (const depId of dependenciesOf(step, producerOf)) {
618
618
  const dep = stepsById.get(depId);
619
- if (dep) visit(dep, [...path21, step.id]);
619
+ if (dep) visit(dep, [...path22, step.id]);
620
620
  }
621
621
  state.set(step.id, "done");
622
622
  }
@@ -1652,11 +1652,28 @@ async function runLoopStep(step, ctx) {
1652
1652
  return "failed";
1653
1653
  }
1654
1654
  const resumeFrom = ctx.loopContinuation?.action === "continue" ? await buildResumeFrom(step, ctx) : void 0;
1655
- const status = await runLoopBody(step, ctx, resumeFrom);
1656
- if (status === "ok") {
1657
- await appendJournalEvent(ctx.runDir, ctx.runId, { t: "step.completed", stepId: step.id, cached: false });
1655
+ const result = await runLoopBody(step, ctx, resumeFrom);
1656
+ if (result.status === "ok") {
1657
+ let artifact;
1658
+ if (step.produces) {
1659
+ const written = await writeArtifact(ctx.runDir, step.produces, result.untilContext, z6.unknown());
1660
+ artifact = { name: step.produces, hash: written.hash };
1661
+ ctx.outerArtifactHashes.set(step.produces, written.hash);
1662
+ await appendJournalEvent(ctx.runDir, ctx.runId, {
1663
+ t: "artifact.written",
1664
+ name: step.produces,
1665
+ hash: written.hash,
1666
+ bytes: written.bytes
1667
+ });
1668
+ }
1669
+ await appendJournalEvent(ctx.runDir, ctx.runId, {
1670
+ t: "step.completed",
1671
+ stepId: step.id,
1672
+ ...artifact ? { artifact: artifact.name, artifactHash: artifact.hash } : {},
1673
+ cached: false
1674
+ });
1658
1675
  }
1659
- return status;
1676
+ return result.status;
1660
1677
  }
1661
1678
  async function buildResumeFrom(step, ctx) {
1662
1679
  const events = await readJournal(ctx.runDir);
@@ -1719,12 +1736,12 @@ async function runLoopBody(step, ctx, resumeFrom) {
1719
1736
  recoverable: false
1720
1737
  }
1721
1738
  });
1722
- return "failed";
1739
+ return { status: "failed" };
1723
1740
  }
1724
1741
  throw err;
1725
1742
  }
1726
1743
  const untilContext = Object.fromEntries(thisValues);
1727
- if (await evalExpr(step.until, untilContext, ctx.cwd)) return "ok";
1744
+ if (await evalExpr(step.until, untilContext, ctx.cwd)) return { status: "ok", untilContext };
1728
1745
  let tripped;
1729
1746
  if (step.budget.noProgress) {
1730
1747
  lastSignal = await evalExpr(step.budget.noProgress.signal, untilContext, ctx.cwd);
@@ -1743,7 +1760,10 @@ async function runLoopBody(step, ctx, resumeFrom) {
1743
1760
  const tokensTotal = await sumBodyTokens(ctx.runDir, bodyStepIds);
1744
1761
  if (tokensTotal >= step.budget.maxTokens) tripped = "maxTokens";
1745
1762
  }
1746
- if (tripped) return await handleExhausted(step, ctx, iteration, tripped);
1763
+ if (tripped) {
1764
+ const outcome = await handleExhausted(step, ctx, iteration, tripped);
1765
+ return outcome === "ok" ? { status: "ok", untilContext } : { status: outcome };
1766
+ }
1747
1767
  priorValues = thisValues;
1748
1768
  priorHashes = thisHashes;
1749
1769
  }
@@ -2398,6 +2418,7 @@ async function executeWorkflowFile(workflowPath, opts = {}) {
2398
2418
  inputHash: inputWritten?.hash ?? sha256(JSON.stringify(workflow)),
2399
2419
  adapter,
2400
2420
  isolation,
2421
+ pid: process.pid,
2401
2422
  ...opts.tag !== void 0 ? { tag: opts.tag } : {}
2402
2423
  });
2403
2424
  if (inputWritten) {
@@ -2486,6 +2507,7 @@ async function resumeRun(runId, opts = {}) {
2486
2507
  const { runsDir, cwd, cacheDir } = resolveDirs(opts.runsDir, opts.cwd, opts.cacheDir);
2487
2508
  const runDir = path15.join(runsDir, runId);
2488
2509
  const workflow = await readRunWorkflow(runDir);
2510
+ await appendJournalEvent(runDir, runId, { t: "run.resumed", pid: process.pid });
2489
2511
  let events = await readJournal(runDir);
2490
2512
  const loopContinuations = await resolveOpenRequests(runId, runDir, workflow, cwd, events);
2491
2513
  events = await readJournal(runDir);
@@ -2549,6 +2571,99 @@ async function artifactsCommand(runId, opts = {}) {
2549
2571
  return 0;
2550
2572
  }
2551
2573
 
2574
+ // src/engine/cancel.ts
2575
+ import path17 from "path";
2576
+ var SIGKILL_GRACE_MS = 5e3;
2577
+ var LIVENESS_POLL_MS = 100;
2578
+ var LIVENESS_POLLS = Math.ceil(SIGKILL_GRACE_MS / LIVENESS_POLL_MS);
2579
+ var realSignal = (pid, signal) => {
2580
+ process.kill(pid, signal);
2581
+ };
2582
+ function livePidFromJournal(events) {
2583
+ for (let i = events.length - 1; i >= 0; i--) {
2584
+ const event = events[i];
2585
+ if (event?.t === "run.resumed") return event.pid;
2586
+ }
2587
+ const started = events.find((e) => e.t === "run.started");
2588
+ return started?.t === "run.started" ? started.pid : void 0;
2589
+ }
2590
+ function isAlive(signal, pid) {
2591
+ try {
2592
+ signal(pid, 0);
2593
+ return true;
2594
+ } catch (err) {
2595
+ return err.code === "EPERM";
2596
+ }
2597
+ }
2598
+ async function terminateProcessTree(signal, sleep, pid) {
2599
+ if (!isAlive(signal, pid)) return "not-running";
2600
+ try {
2601
+ signal(-pid, "SIGTERM");
2602
+ } catch {
2603
+ try {
2604
+ signal(pid, "SIGTERM");
2605
+ } catch {
2606
+ return "sigterm";
2607
+ }
2608
+ }
2609
+ for (let poll = 0; poll < LIVENESS_POLLS; poll++) {
2610
+ if (!isAlive(signal, pid)) return "sigterm";
2611
+ await sleep(LIVENESS_POLL_MS);
2612
+ }
2613
+ if (!isAlive(signal, pid)) return "sigterm";
2614
+ try {
2615
+ signal(-pid, "SIGKILL");
2616
+ } catch {
2617
+ try {
2618
+ signal(pid, "SIGKILL");
2619
+ } catch {
2620
+ }
2621
+ }
2622
+ return "sigkill";
2623
+ }
2624
+ async function cancelRun(runId, opts = {}) {
2625
+ const runsDir = opts.runsDir ?? defaultRunsDir();
2626
+ const signal = opts.signal ?? realSignal;
2627
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
2628
+ const runDir = path17.join(runsDir, runId);
2629
+ const events = await readJournal(runDir);
2630
+ if (!events.some((e) => e.t === "run.started")) return { status: "not-found", runId };
2631
+ const last = events.at(-1);
2632
+ if (last?.t === "run.finished") {
2633
+ return { status: "already-terminal", runId, finishedStatus: last.status };
2634
+ }
2635
+ const pid = livePidFromJournal(events);
2636
+ const signalled = pid === void 0 ? "no-pid" : await terminateProcessTree(signal, sleep, pid);
2637
+ const settled = (await readJournal(runDir)).at(-1);
2638
+ if (settled?.t === "run.finished") {
2639
+ return { status: "already-terminal", runId, finishedStatus: settled.status };
2640
+ }
2641
+ await appendJournalEvent(runDir, runId, {
2642
+ t: "run.finished",
2643
+ status: "failed",
2644
+ reason: "cancelled"
2645
+ });
2646
+ return { status: "cancelled", runId, signalled };
2647
+ }
2648
+
2649
+ // src/cli/commands/cancel.ts
2650
+ async function cancelCommand(runId, opts = {}) {
2651
+ const outcome = await cancelRun(runId, { runsDir: opts.runsDir });
2652
+ switch (outcome.status) {
2653
+ case "not-found":
2654
+ console.error(`run ${runId} not found`);
2655
+ return 1;
2656
+ case "already-terminal":
2657
+ console.log(`run ${runId} already finished: ${outcome.finishedStatus} \u2014 nothing to cancel`);
2658
+ return 0;
2659
+ case "cancelled": {
2660
+ const note = outcome.signalled === "no-pid" ? " (no pid recorded \u2014 marked cancelled without signalling)" : outcome.signalled === "not-running" ? " (process already gone \u2014 marked cancelled)" : outcome.signalled === "sigkill" ? " (SIGKILL after grace period)" : "";
2661
+ console.log(`run ${runId} cancelled${note}`);
2662
+ return 0;
2663
+ }
2664
+ }
2665
+ }
2666
+
2552
2667
  // src/cli/commands/graph.ts
2553
2668
  async function graphCommand(workflowPath) {
2554
2669
  const raw = await loadWorkflowYaml(workflowPath);
@@ -2559,13 +2674,13 @@ async function graphCommand(workflowPath) {
2559
2674
 
2560
2675
  // src/cli/commands/pending.ts
2561
2676
  import { readdir as readdir3 } from "fs/promises";
2562
- import path17 from "path";
2677
+ import path18 from "path";
2563
2678
  async function pendingCommand(opts = {}) {
2564
2679
  const runsDir = opts.runsDir ?? defaultRunsDir();
2565
2680
  const runIds = (await readdir3(runsDir).catch(() => [])).sort();
2566
2681
  let anyPending = false;
2567
2682
  for (const runId of runIds) {
2568
- const runDir = path17.join(runsDir, runId);
2683
+ const runDir = path18.join(runsDir, runId);
2569
2684
  const events = await readJournal(runDir);
2570
2685
  const last = events.at(-1);
2571
2686
  if (!last || last.t !== "run.finished" || last.status !== "suspended") continue;
@@ -2669,7 +2784,7 @@ async function resumeCommand(runId, adapter) {
2669
2784
  }
2670
2785
 
2671
2786
  // src/cli/commands/run.ts
2672
- import path18 from "path";
2787
+ import path19 from "path";
2673
2788
  import { cancel, intro, log, outro } from "@clack/prompts";
2674
2789
 
2675
2790
  // src/cli/interactive.ts
@@ -2799,7 +2914,7 @@ ${text2}
2799
2914
  async function runCommand(workflowPath, opts = {}) {
2800
2915
  let result;
2801
2916
  try {
2802
- result = await executeWorkflowFile(path18.resolve(workflowPath), {
2917
+ result = await executeWorkflowFile(path19.resolve(workflowPath), {
2803
2918
  adapter: opts.adapter,
2804
2919
  isolation: opts.isolation,
2805
2920
  input: opts.input,
@@ -2839,7 +2954,7 @@ async function runCommand(workflowPath, opts = {}) {
2839
2954
  }
2840
2955
 
2841
2956
  // src/cli/commands/status.ts
2842
- import path19 from "path";
2957
+ import path20 from "path";
2843
2958
  async function statusCommand(runId, opts = {}) {
2844
2959
  const runsDir = opts.runsDir ?? defaultRunsDir();
2845
2960
  const resolvedRunId = runId ?? await findLatestRunId(runsDir);
@@ -2847,7 +2962,7 @@ async function statusCommand(runId, opts = {}) {
2847
2962
  console.error("no runs found");
2848
2963
  return 1;
2849
2964
  }
2850
- const runDir = path19.join(runsDir, resolvedRunId);
2965
+ const runDir = path20.join(runsDir, resolvedRunId);
2851
2966
  const workflow = await readRunWorkflow(runDir).catch(() => void 0);
2852
2967
  if (!workflow) {
2853
2968
  console.error(`run ${resolvedRunId} not found`);
@@ -2873,7 +2988,7 @@ async function statusCommand(runId, opts = {}) {
2873
2988
  }
2874
2989
 
2875
2990
  // src/cli/commands/watch.ts
2876
- import path20 from "path";
2991
+ import path21 from "path";
2877
2992
  import { render } from "ink";
2878
2993
  import React2 from "react";
2879
2994
 
@@ -2933,7 +3048,7 @@ async function watchCommand(runId, opts = {}) {
2933
3048
  console.error("no runs found");
2934
3049
  return 1;
2935
3050
  }
2936
- const runDir = path20.join(runsDir, resolvedRunId);
3051
+ const runDir = path21.join(runsDir, resolvedRunId);
2937
3052
  const workflow = await readRunWorkflow(runDir).catch(() => void 0);
2938
3053
  if (!workflow) {
2939
3054
  console.error(`run ${resolvedRunId} not found`);
@@ -3010,6 +3125,9 @@ program.command("run").description("Run a workflow YAML file").argument("<workfl
3010
3125
  program.command("resume").description("Resume an interrupted run").argument("<run-id>", "id of the run to resume").option("--adapter <adapter>", "must match the adapter the run started with, if given").action(async (runId, options) => {
3011
3126
  process.exitCode = await resumeCommand(runId, options.adapter ? requireAdapterId(options.adapter) : void 0);
3012
3127
  });
3128
+ program.command("cancel").description("Terminate a live run and journal a clean 'cancelled' terminal state").argument("<run-id>", "id of the run to cancel").action(async (runId) => {
3129
+ process.exitCode = await cancelCommand(runId);
3130
+ });
3013
3131
  program.command("status").description("Report a run's per-step status").argument("[run-id]", "id of the run to report on (default: most recent)").action(async (runId) => {
3014
3132
  process.exitCode = await statusCommand(runId);
3015
3133
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lchase/yak",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "An agentic workflow engine",
5
5
  "license": "MIT",
6
6
  "type": "module",