@lchase/yak 0.4.1 → 0.6.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 +387 -43
  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
@@ -100,6 +100,16 @@ function inputNamesOf(step) {
100
100
  function dependenciesOf(step, producerOf) {
101
101
  return inputNamesOf(step).map((need) => producerOf.get(need)).filter((id) => id !== void 0);
102
102
  }
103
+ function earlierProducerIds(step, posIndex, producerOf, order) {
104
+ const deps = [];
105
+ for (const name of inputNamesOf(step)) {
106
+ const producerId = producerOf.get(name);
107
+ if (producerId === void 0) continue;
108
+ const producerPos = order.get(producerId);
109
+ if (producerPos !== void 0 && producerPos < posIndex) deps.push(producerId);
110
+ }
111
+ return deps;
112
+ }
103
113
  function flattenSteps(steps) {
104
114
  const result = [];
105
115
  for (const step of steps) {
@@ -191,6 +201,7 @@ var rawStepSchema = z2.lazy(
191
201
  loop: z2.object({
192
202
  until: rawExprSchema,
193
203
  budget: rawBudgetSchema,
204
+ concurrency: z2.number().int().positive().optional(),
194
205
  onExhausted: z2.enum(["suspend", "fail", "continue"]).optional(),
195
206
  freshContext: z2.boolean().optional(),
196
207
  body: z2.array(rawStepSchema)
@@ -281,6 +292,7 @@ function normalizeStep(raw) {
281
292
  body: raw.loop.body.map(normalizeStep),
282
293
  until: raw.loop.until,
283
294
  budget: raw.loop.budget,
295
+ concurrency: raw.loop.concurrency ?? 1,
284
296
  onExhausted: raw.loop.onExhausted ?? "suspend",
285
297
  freshContext: raw.loop.freshContext ?? true,
286
298
  ...raw.produces !== void 0 ? { produces: raw.produces } : {}
@@ -331,14 +343,76 @@ import { promisify } from "util";
331
343
  // src/expr/template.ts
332
344
  import jexl from "jexl";
333
345
  var PLACEHOLDER_RE = /\{\{\s*([^}]+?)\s*\}\}/g;
346
+ var IDENTIFIER = /[A-Za-z_$][A-Za-z0-9_$]*/y;
347
+ var NOT_A_ROOT = /* @__PURE__ */ new Set(["true", "false", "in"]);
334
348
  function extractTemplateRoots(template) {
335
- const roots = /* @__PURE__ */ new Set();
349
+ const roots = [];
350
+ const seen = /* @__PURE__ */ new Set();
336
351
  for (const match of template.matchAll(PLACEHOLDER_RE)) {
337
- const expr = match[1].trim();
338
- const root = expr.split(/[.[]/, 1)[0];
339
- if (root) roots.add(root);
352
+ for (const ident of identifiersInExpr(match[1])) {
353
+ if (seen.has(ident)) continue;
354
+ seen.add(ident);
355
+ roots.push(ident);
356
+ }
357
+ }
358
+ return roots;
359
+ }
360
+ function identifiersInExpr(expr) {
361
+ const found = [];
362
+ let prev = "other";
363
+ let i = 0;
364
+ while (i < expr.length) {
365
+ const c = expr[i];
366
+ if (c === " " || c === " " || c === "\n" || c === "\r") {
367
+ i++;
368
+ continue;
369
+ }
370
+ if (c === '"' || c === "'") {
371
+ i = skipString(expr, i);
372
+ prev = "other";
373
+ continue;
374
+ }
375
+ if (c === "|" && expr[i + 1] === "|") {
376
+ i += 2;
377
+ prev = "other";
378
+ continue;
379
+ }
380
+ if (c === "|") {
381
+ i++;
382
+ prev = "pipe";
383
+ continue;
384
+ }
385
+ if (c === ".") {
386
+ i++;
387
+ prev = "dot";
388
+ continue;
389
+ }
390
+ IDENTIFIER.lastIndex = i;
391
+ const ident = IDENTIFIER.exec(expr);
392
+ if (ident && ident.index === i) {
393
+ const name = ident[0];
394
+ if (prev !== "dot" && prev !== "pipe" && !NOT_A_ROOT.has(name)) found.push(name);
395
+ i += name.length;
396
+ prev = "other";
397
+ continue;
398
+ }
399
+ i++;
400
+ prev = "other";
340
401
  }
341
- return [...roots];
402
+ return found;
403
+ }
404
+ function skipString(expr, start) {
405
+ const quote = expr[start];
406
+ let i = start + 1;
407
+ while (i < expr.length) {
408
+ if (expr[i] === "\\") {
409
+ i += 2;
410
+ continue;
411
+ }
412
+ if (expr[i] === quote) return i + 1;
413
+ i++;
414
+ }
415
+ return i;
342
416
  }
343
417
  async function renderTemplate(template, inputs) {
344
418
  let result = "";
@@ -483,6 +557,7 @@ var KNOWN_AGENT_TOOLS = /* @__PURE__ */ new Set([
483
557
  async function validateWorkflow(workflow, cwd = process.cwd(), runIsolation = "none") {
484
558
  const flatSteps = flattenSteps(workflow.steps);
485
559
  checkKnownKinds(flatSteps);
560
+ checkLoopBodyKinds(workflow.steps);
486
561
  checkDuplicateIds(flatSteps);
487
562
  const producerOf = buildProducerMap(workflow.steps);
488
563
  checkReservedArtifactNames(flatSteps);
@@ -495,6 +570,7 @@ async function validateWorkflow(workflow, cwd = process.cwd(), runIsolation = "n
495
570
  await checkAgentPromptPlaceholders(flatSteps, cwd);
496
571
  checkSessionChainDepth(flatSteps);
497
572
  checkMapIsolation(workflow.steps, runIsolation);
573
+ checkLoopParallelWriters(workflow.steps);
498
574
  await checkGateSchemaKeys(flatSteps, cwd);
499
575
  await checkGateRenderPlaceholders(flatSteps, cwd);
500
576
  checkSandboxImage(flatSteps);
@@ -547,6 +623,83 @@ function checkMapIsolation(steps, runIsolation) {
547
623
  checkMapIsolation([step.step], runIsolation);
548
624
  }
549
625
  }
626
+ var UNSUPPORTED_LOOP_BODY_KINDS = /* @__PURE__ */ new Set(["map", "loop", "gate"]);
627
+ function checkLoopBodyKinds(steps) {
628
+ for (const step of steps) {
629
+ if (step.kind === "map") {
630
+ checkLoopBodyKinds([step.step]);
631
+ continue;
632
+ }
633
+ if (step.kind !== "loop") continue;
634
+ for (const bodyStep of step.body) {
635
+ if (UNSUPPORTED_LOOP_BODY_KINDS.has(bodyStep.kind)) {
636
+ throw new WorkflowValidationError(
637
+ `step "${bodyStep.id}": kind "${bodyStep.kind}" not supported inside a loop body in M3`
638
+ );
639
+ }
640
+ }
641
+ checkLoopBodyKinds(step.body);
642
+ }
643
+ }
644
+ function checkLoopParallelWriters(steps) {
645
+ for (const step of steps) {
646
+ if (step.kind === "loop") {
647
+ warnParallelWriteCapableBody(step);
648
+ checkLoopParallelWriters(step.body);
649
+ } else if (step.kind === "map") {
650
+ checkLoopParallelWriters([step.step]);
651
+ }
652
+ }
653
+ }
654
+ function writeCapableTool(step) {
655
+ if (step.kind !== "agent") return void 0;
656
+ return (step.tools ?? []).find((tool) => WRITE_CAPABLE_TOOLS.has(tool));
657
+ }
658
+ function waitsOn(step, targetId, stepsById, producerOf, order) {
659
+ const seen = /* @__PURE__ */ new Set();
660
+ const startPos = order.get(step.id);
661
+ if (startPos === void 0) return false;
662
+ const stack = earlierProducerIds(step, startPos, producerOf, order);
663
+ while (stack.length > 0) {
664
+ const id = stack.pop();
665
+ if (id === void 0 || seen.has(id)) continue;
666
+ if (id === targetId) return true;
667
+ seen.add(id);
668
+ const pred = stepsById.get(id);
669
+ const predPos = order.get(id);
670
+ if (!pred || predPos === void 0) continue;
671
+ stack.push(...earlierProducerIds(pred, predPos, producerOf, order));
672
+ }
673
+ return false;
674
+ }
675
+ function warnParallelWriteCapableBody(loop) {
676
+ const concurrency = loop.concurrency ?? 1;
677
+ if (concurrency <= 1 || loop.body.length < 2) return;
678
+ const order = new Map(loop.body.map((bodyStep, index) => [bodyStep.id, index]));
679
+ const stepsById = new Map(loop.body.map((bodyStep) => [bodyStep.id, bodyStep]));
680
+ const producerOf = buildProducerMap(loop.body);
681
+ const warned = /* @__PURE__ */ new Set();
682
+ for (let i = 0; i < loop.body.length; i++) {
683
+ for (let j = i + 1; j < loop.body.length; j++) {
684
+ const earlier = loop.body[i];
685
+ const later = loop.body[j];
686
+ if (!earlier || !later) continue;
687
+ if (waitsOn(later, earlier.id, stepsById, producerOf, order)) continue;
688
+ for (const bodyStep of [earlier, later]) {
689
+ if (warned.has(bodyStep.id)) continue;
690
+ const tool = writeCapableTool(bodyStep);
691
+ const command = bodyStep.kind === "command";
692
+ if (!tool && !command) continue;
693
+ const why = command ? "is a command step" : `declares write-capable tool "${tool}"`;
694
+ const fix = command ? "use concurrency: 1" : "use concurrency: 1 or drop the write-capable tool";
695
+ console.warn(
696
+ `step "${loop.id}": concurrency ${concurrency} runs body steps "${earlier.id}" and "${later.id}" in parallel, and "${bodyStep.id}" ${why} \u2014 concurrent body steps share one cwd and may race; ${fix}`
697
+ );
698
+ warned.add(bodyStep.id);
699
+ }
700
+ }
701
+ }
702
+ }
550
703
  function checkAgentToolNames(steps) {
551
704
  for (const step of steps) {
552
705
  if (step.kind !== "agent") continue;
@@ -606,17 +759,17 @@ function checkNeedsSatisfied(steps, producerOf) {
606
759
  function checkNoCycles(steps, producerOf) {
607
760
  const stepsById = new Map(steps.map((s) => [s.id, s]));
608
761
  const state = /* @__PURE__ */ new Map();
609
- function visit(step, path21) {
762
+ function visit(step, path22) {
610
763
  const mark = state.get(step.id);
611
764
  if (mark === "done") return;
612
765
  if (mark === "visiting") {
613
- const cycle = [...path21, step.id].join(" -> ");
766
+ const cycle = [...path22, step.id].join(" -> ");
614
767
  throw new WorkflowValidationError(`cycle detected: ${cycle}`);
615
768
  }
616
769
  state.set(step.id, "visiting");
617
770
  for (const depId of dependenciesOf(step, producerOf)) {
618
771
  const dep = stepsById.get(depId);
619
- if (dep) visit(dep, [...path21, step.id]);
772
+ if (dep) visit(dep, [...path22, step.id]);
620
773
  }
621
774
  state.set(step.id, "done");
622
775
  }
@@ -1038,7 +1191,7 @@ async function createWorktree(repoRoot, branchName, baseRef, worktreePath) {
1038
1191
 
1039
1192
  // src/engine/scheduler.ts
1040
1193
  import path14 from "path";
1041
- import pLimit2 from "p-limit";
1194
+ import pLimit3 from "p-limit";
1042
1195
  import { z as z8 } from "zod";
1043
1196
 
1044
1197
  // src/adapters/claude-code.ts
@@ -1534,6 +1687,8 @@ function runCommandStep(step, cwd, extraEnv) {
1534
1687
  }
1535
1688
 
1536
1689
  // src/steps/loop.ts
1690
+ import assert from "assert/strict";
1691
+ import pLimit from "p-limit";
1537
1692
  import { z as z6 } from "zod";
1538
1693
 
1539
1694
  // src/engine/cache.ts
@@ -1652,11 +1807,28 @@ async function runLoopStep(step, ctx) {
1652
1807
  return "failed";
1653
1808
  }
1654
1809
  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 });
1810
+ const result = await runLoopBody(step, ctx, resumeFrom);
1811
+ if (result.status === "ok") {
1812
+ let artifact;
1813
+ if (step.produces) {
1814
+ const written = await writeArtifact(ctx.runDir, step.produces, result.untilContext, z6.unknown());
1815
+ artifact = { name: step.produces, hash: written.hash };
1816
+ ctx.outerArtifactHashes.set(step.produces, written.hash);
1817
+ await appendJournalEvent(ctx.runDir, ctx.runId, {
1818
+ t: "artifact.written",
1819
+ name: step.produces,
1820
+ hash: written.hash,
1821
+ bytes: written.bytes
1822
+ });
1823
+ }
1824
+ await appendJournalEvent(ctx.runDir, ctx.runId, {
1825
+ t: "step.completed",
1826
+ stepId: step.id,
1827
+ ...artifact ? { artifact: artifact.name, artifactHash: artifact.hash } : {},
1828
+ cached: false
1829
+ });
1658
1830
  }
1659
- return status;
1831
+ return result.status;
1660
1832
  }
1661
1833
  async function buildResumeFrom(step, ctx) {
1662
1834
  const events = await readJournal(ctx.runDir);
@@ -1692,22 +1864,18 @@ async function runLoopBody(step, ctx, resumeFrom) {
1692
1864
  const thisValues = /* @__PURE__ */ new Map();
1693
1865
  const thisHashes = /* @__PURE__ */ new Map();
1694
1866
  try {
1695
- for (const [posIndex, bodyStep] of step.body.entries()) {
1696
- await runOneBodyStep({
1697
- bodyStep,
1698
- posIndex,
1699
- iteration,
1700
- ctx,
1701
- localProducerOf,
1702
- bodyOrderIndex,
1703
- priorValues,
1704
- priorHashes,
1705
- thisValues,
1706
- thisHashes,
1707
- freshContext: step.freshContext ?? true,
1708
- bodySessionIds
1709
- });
1710
- }
1867
+ await runLoopRound({
1868
+ step,
1869
+ iteration,
1870
+ ctx,
1871
+ localProducerOf,
1872
+ bodyOrderIndex,
1873
+ priorValues,
1874
+ priorHashes,
1875
+ thisValues,
1876
+ thisHashes,
1877
+ bodySessionIds
1878
+ });
1711
1879
  } catch (err) {
1712
1880
  if (err instanceof CommandStepFailedError || err instanceof AgentStepFailedError) {
1713
1881
  await appendJournalEvent(ctx.runDir, ctx.runId, {
@@ -1719,12 +1887,12 @@ async function runLoopBody(step, ctx, resumeFrom) {
1719
1887
  recoverable: false
1720
1888
  }
1721
1889
  });
1722
- return "failed";
1890
+ return { status: "failed" };
1723
1891
  }
1724
1892
  throw err;
1725
1893
  }
1726
1894
  const untilContext = Object.fromEntries(thisValues);
1727
- if (await evalExpr(step.until, untilContext, ctx.cwd)) return "ok";
1895
+ if (await evalExpr(step.until, untilContext, ctx.cwd)) return { status: "ok", untilContext };
1728
1896
  let tripped;
1729
1897
  if (step.budget.noProgress) {
1730
1898
  lastSignal = await evalExpr(step.budget.noProgress.signal, untilContext, ctx.cwd);
@@ -1743,7 +1911,10 @@ async function runLoopBody(step, ctx, resumeFrom) {
1743
1911
  const tokensTotal = await sumBodyTokens(ctx.runDir, bodyStepIds);
1744
1912
  if (tokensTotal >= step.budget.maxTokens) tripped = "maxTokens";
1745
1913
  }
1746
- if (tripped) return await handleExhausted(step, ctx, iteration, tripped);
1914
+ if (tripped) {
1915
+ const outcome = await handleExhausted(step, ctx, iteration, tripped);
1916
+ return outcome === "ok" ? { status: "ok", untilContext } : { status: outcome };
1917
+ }
1747
1918
  priorValues = thisValues;
1748
1919
  priorHashes = thisHashes;
1749
1920
  }
@@ -1788,6 +1959,80 @@ async function handleExhausted(step, ctx, iteration, tripped) {
1788
1959
  await writeLoopExhaustedRequest(ctx.runDir, ctx.runId, step.id, iteration, tripped);
1789
1960
  return "suspended";
1790
1961
  }
1962
+ async function runLoopRound(args) {
1963
+ const {
1964
+ step,
1965
+ iteration,
1966
+ ctx,
1967
+ localProducerOf,
1968
+ bodyOrderIndex,
1969
+ priorValues,
1970
+ priorHashes,
1971
+ thisValues,
1972
+ thisHashes,
1973
+ bodySessionIds
1974
+ } = args;
1975
+ const cap = Math.min(step.concurrency ?? 1, ctx.globalConcurrency);
1976
+ const limit = pLimit(cap);
1977
+ const remaining = new Set(step.body.map((bodyStep) => bodyStep.id));
1978
+ const completed = /* @__PURE__ */ new Set();
1979
+ const inFlight = /* @__PURE__ */ new Map();
1980
+ let firstError;
1981
+ const thisRoundDeps = (bodyStep, posIndex) => earlierProducerIds(bodyStep, posIndex, localProducerOf, bodyOrderIndex);
1982
+ const launchEligible = () => {
1983
+ if (firstError !== void 0) return;
1984
+ for (const [posIndex, bodyStep] of step.body.entries()) {
1985
+ if (!remaining.has(bodyStep.id) || inFlight.has(bodyStep.id)) continue;
1986
+ const ready = thisRoundDeps(bodyStep, posIndex).every((dep) => completed.has(dep));
1987
+ if (!ready) {
1988
+ if (cap === 1) break;
1989
+ continue;
1990
+ }
1991
+ inFlight.set(
1992
+ bodyStep.id,
1993
+ limit(async () => {
1994
+ if (firstError !== void 0) return "skipped";
1995
+ try {
1996
+ await runOneBodyStep({
1997
+ bodyStep,
1998
+ posIndex,
1999
+ iteration,
2000
+ ctx,
2001
+ localProducerOf,
2002
+ bodyOrderIndex,
2003
+ priorValues,
2004
+ priorHashes,
2005
+ thisValues,
2006
+ thisHashes,
2007
+ freshContext: step.freshContext ?? true,
2008
+ bodySessionIds
2009
+ });
2010
+ return "ok";
2011
+ } catch (err) {
2012
+ if (firstError === void 0) firstError = err;
2013
+ return "error";
2014
+ }
2015
+ })
2016
+ );
2017
+ }
2018
+ };
2019
+ launchEligible();
2020
+ while (inFlight.size > 0) {
2021
+ const [doneId, status] = await Promise.race(
2022
+ [...inFlight.entries()].map(async ([id, p]) => [id, await p])
2023
+ );
2024
+ inFlight.delete(doneId);
2025
+ remaining.delete(doneId);
2026
+ if (status === "ok") completed.add(doneId);
2027
+ launchEligible();
2028
+ }
2029
+ if (firstError !== void 0) throw firstError;
2030
+ assert.equal(
2031
+ remaining.size,
2032
+ 0,
2033
+ `loop "${step.id}" stalled: body step(s) [${[...remaining].join(", ")}] never became eligible`
2034
+ );
2035
+ }
1791
2036
  async function runOneBodyStep(args) {
1792
2037
  const {
1793
2038
  bodyStep,
@@ -1903,7 +2148,7 @@ async function runBodyStepKind(bodyStep, inputs, ctx, iteration, freshContext, b
1903
2148
 
1904
2149
  // src/steps/map.ts
1905
2150
  import path13 from "path";
1906
- import pLimit from "p-limit";
2151
+ import pLimit2 from "p-limit";
1907
2152
  import { z as z7 } from "zod";
1908
2153
  var RETRY_ATTEMPTS = 2;
1909
2154
  async function runMapStep(step, ctx) {
@@ -1917,7 +2162,7 @@ async function runMapStep(step, ctx) {
1917
2162
  await commitAllIfDirty(ctx.cwd, `yak: checkpoint before map ${step.id}`);
1918
2163
  }
1919
2164
  const items = await readArtifactRaw(ctx.runDir, step.over);
1920
- const limit = pLimit(Math.min(step.concurrency ?? 4, ctx.globalConcurrency));
2165
+ const limit = pLimit2(Math.min(step.concurrency ?? 4, ctx.globalConcurrency));
1921
2166
  const results = new Array(items.length);
1922
2167
  let anyFailed = false;
1923
2168
  await Promise.all(
@@ -2122,7 +2367,7 @@ function trustResumedSteps(remaining, producerOf, resumeState, completed, artifa
2122
2367
  }
2123
2368
  }
2124
2369
  async function runEligibleSteps(workflow, ctx, resumeState) {
2125
- const limit = pLimit2(ctx.concurrency ?? DEFAULT_CONCURRENCY);
2370
+ const limit = pLimit3(ctx.concurrency ?? DEFAULT_CONCURRENCY);
2126
2371
  const producerOf = buildProducerMap(workflow.steps);
2127
2372
  const remaining = new Map(workflow.steps.map((s) => [s.id, s]));
2128
2373
  const completed = /* @__PURE__ */ new Set();
@@ -2188,6 +2433,7 @@ async function runStep(step, ctx, artifactHashes, workflowName, agentSessionIds)
2188
2433
  semanticKey: semanticKey2,
2189
2434
  definitionKey: definitionKey2,
2190
2435
  outerArtifactHashes: artifactHashes,
2436
+ globalConcurrency: ctx.concurrency ?? DEFAULT_CONCURRENCY,
2191
2437
  buildAdapter: (step2, iteration) => buildAdapter(ctx.adapter, ctx, workflowName, step2, iteration),
2192
2438
  loopContinuation: ctx.loopContinuations?.get(step.id)
2193
2439
  });
@@ -2398,6 +2644,7 @@ async function executeWorkflowFile(workflowPath, opts = {}) {
2398
2644
  inputHash: inputWritten?.hash ?? sha256(JSON.stringify(workflow)),
2399
2645
  adapter,
2400
2646
  isolation,
2647
+ pid: process.pid,
2401
2648
  ...opts.tag !== void 0 ? { tag: opts.tag } : {}
2402
2649
  });
2403
2650
  if (inputWritten) {
@@ -2486,6 +2733,7 @@ async function resumeRun(runId, opts = {}) {
2486
2733
  const { runsDir, cwd, cacheDir } = resolveDirs(opts.runsDir, opts.cwd, opts.cacheDir);
2487
2734
  const runDir = path15.join(runsDir, runId);
2488
2735
  const workflow = await readRunWorkflow(runDir);
2736
+ await appendJournalEvent(runDir, runId, { t: "run.resumed", pid: process.pid });
2489
2737
  let events = await readJournal(runDir);
2490
2738
  const loopContinuations = await resolveOpenRequests(runId, runDir, workflow, cwd, events);
2491
2739
  events = await readJournal(runDir);
@@ -2549,6 +2797,99 @@ async function artifactsCommand(runId, opts = {}) {
2549
2797
  return 0;
2550
2798
  }
2551
2799
 
2800
+ // src/engine/cancel.ts
2801
+ import path17 from "path";
2802
+ var SIGKILL_GRACE_MS = 5e3;
2803
+ var LIVENESS_POLL_MS = 100;
2804
+ var LIVENESS_POLLS = Math.ceil(SIGKILL_GRACE_MS / LIVENESS_POLL_MS);
2805
+ var realSignal = (pid, signal) => {
2806
+ process.kill(pid, signal);
2807
+ };
2808
+ function livePidFromJournal(events) {
2809
+ for (let i = events.length - 1; i >= 0; i--) {
2810
+ const event = events[i];
2811
+ if (event?.t === "run.resumed") return event.pid;
2812
+ }
2813
+ const started = events.find((e) => e.t === "run.started");
2814
+ return started?.t === "run.started" ? started.pid : void 0;
2815
+ }
2816
+ function isAlive(signal, pid) {
2817
+ try {
2818
+ signal(pid, 0);
2819
+ return true;
2820
+ } catch (err) {
2821
+ return err.code === "EPERM";
2822
+ }
2823
+ }
2824
+ async function terminateProcessTree(signal, sleep, pid) {
2825
+ if (!isAlive(signal, pid)) return "not-running";
2826
+ try {
2827
+ signal(-pid, "SIGTERM");
2828
+ } catch {
2829
+ try {
2830
+ signal(pid, "SIGTERM");
2831
+ } catch {
2832
+ return "sigterm";
2833
+ }
2834
+ }
2835
+ for (let poll = 0; poll < LIVENESS_POLLS; poll++) {
2836
+ if (!isAlive(signal, pid)) return "sigterm";
2837
+ await sleep(LIVENESS_POLL_MS);
2838
+ }
2839
+ if (!isAlive(signal, pid)) return "sigterm";
2840
+ try {
2841
+ signal(-pid, "SIGKILL");
2842
+ } catch {
2843
+ try {
2844
+ signal(pid, "SIGKILL");
2845
+ } catch {
2846
+ }
2847
+ }
2848
+ return "sigkill";
2849
+ }
2850
+ async function cancelRun(runId, opts = {}) {
2851
+ const runsDir = opts.runsDir ?? defaultRunsDir();
2852
+ const signal = opts.signal ?? realSignal;
2853
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
2854
+ const runDir = path17.join(runsDir, runId);
2855
+ const events = await readJournal(runDir);
2856
+ if (!events.some((e) => e.t === "run.started")) return { status: "not-found", runId };
2857
+ const last = events.at(-1);
2858
+ if (last?.t === "run.finished") {
2859
+ return { status: "already-terminal", runId, finishedStatus: last.status };
2860
+ }
2861
+ const pid = livePidFromJournal(events);
2862
+ const signalled = pid === void 0 ? "no-pid" : await terminateProcessTree(signal, sleep, pid);
2863
+ const settled = (await readJournal(runDir)).at(-1);
2864
+ if (settled?.t === "run.finished") {
2865
+ return { status: "already-terminal", runId, finishedStatus: settled.status };
2866
+ }
2867
+ await appendJournalEvent(runDir, runId, {
2868
+ t: "run.finished",
2869
+ status: "failed",
2870
+ reason: "cancelled"
2871
+ });
2872
+ return { status: "cancelled", runId, signalled };
2873
+ }
2874
+
2875
+ // src/cli/commands/cancel.ts
2876
+ async function cancelCommand(runId, opts = {}) {
2877
+ const outcome = await cancelRun(runId, { runsDir: opts.runsDir });
2878
+ switch (outcome.status) {
2879
+ case "not-found":
2880
+ console.error(`run ${runId} not found`);
2881
+ return 1;
2882
+ case "already-terminal":
2883
+ console.log(`run ${runId} already finished: ${outcome.finishedStatus} \u2014 nothing to cancel`);
2884
+ return 0;
2885
+ case "cancelled": {
2886
+ 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)" : "";
2887
+ console.log(`run ${runId} cancelled${note}`);
2888
+ return 0;
2889
+ }
2890
+ }
2891
+ }
2892
+
2552
2893
  // src/cli/commands/graph.ts
2553
2894
  async function graphCommand(workflowPath) {
2554
2895
  const raw = await loadWorkflowYaml(workflowPath);
@@ -2559,13 +2900,13 @@ async function graphCommand(workflowPath) {
2559
2900
 
2560
2901
  // src/cli/commands/pending.ts
2561
2902
  import { readdir as readdir3 } from "fs/promises";
2562
- import path17 from "path";
2903
+ import path18 from "path";
2563
2904
  async function pendingCommand(opts = {}) {
2564
2905
  const runsDir = opts.runsDir ?? defaultRunsDir();
2565
2906
  const runIds = (await readdir3(runsDir).catch(() => [])).sort();
2566
2907
  let anyPending = false;
2567
2908
  for (const runId of runIds) {
2568
- const runDir = path17.join(runsDir, runId);
2909
+ const runDir = path18.join(runsDir, runId);
2569
2910
  const events = await readJournal(runDir);
2570
2911
  const last = events.at(-1);
2571
2912
  if (!last || last.t !== "run.finished" || last.status !== "suspended") continue;
@@ -2669,7 +3010,7 @@ async function resumeCommand(runId, adapter) {
2669
3010
  }
2670
3011
 
2671
3012
  // src/cli/commands/run.ts
2672
- import path18 from "path";
3013
+ import path19 from "path";
2673
3014
  import { cancel, intro, log, outro } from "@clack/prompts";
2674
3015
 
2675
3016
  // src/cli/interactive.ts
@@ -2799,7 +3140,7 @@ ${text2}
2799
3140
  async function runCommand(workflowPath, opts = {}) {
2800
3141
  let result;
2801
3142
  try {
2802
- result = await executeWorkflowFile(path18.resolve(workflowPath), {
3143
+ result = await executeWorkflowFile(path19.resolve(workflowPath), {
2803
3144
  adapter: opts.adapter,
2804
3145
  isolation: opts.isolation,
2805
3146
  input: opts.input,
@@ -2839,7 +3180,7 @@ async function runCommand(workflowPath, opts = {}) {
2839
3180
  }
2840
3181
 
2841
3182
  // src/cli/commands/status.ts
2842
- import path19 from "path";
3183
+ import path20 from "path";
2843
3184
  async function statusCommand(runId, opts = {}) {
2844
3185
  const runsDir = opts.runsDir ?? defaultRunsDir();
2845
3186
  const resolvedRunId = runId ?? await findLatestRunId(runsDir);
@@ -2847,7 +3188,7 @@ async function statusCommand(runId, opts = {}) {
2847
3188
  console.error("no runs found");
2848
3189
  return 1;
2849
3190
  }
2850
- const runDir = path19.join(runsDir, resolvedRunId);
3191
+ const runDir = path20.join(runsDir, resolvedRunId);
2851
3192
  const workflow = await readRunWorkflow(runDir).catch(() => void 0);
2852
3193
  if (!workflow) {
2853
3194
  console.error(`run ${resolvedRunId} not found`);
@@ -2873,7 +3214,7 @@ async function statusCommand(runId, opts = {}) {
2873
3214
  }
2874
3215
 
2875
3216
  // src/cli/commands/watch.ts
2876
- import path20 from "path";
3217
+ import path21 from "path";
2877
3218
  import { render } from "ink";
2878
3219
  import React2 from "react";
2879
3220
 
@@ -2933,7 +3274,7 @@ async function watchCommand(runId, opts = {}) {
2933
3274
  console.error("no runs found");
2934
3275
  return 1;
2935
3276
  }
2936
- const runDir = path20.join(runsDir, resolvedRunId);
3277
+ const runDir = path21.join(runsDir, resolvedRunId);
2937
3278
  const workflow = await readRunWorkflow(runDir).catch(() => void 0);
2938
3279
  if (!workflow) {
2939
3280
  console.error(`run ${resolvedRunId} not found`);
@@ -3010,6 +3351,9 @@ program.command("run").description("Run a workflow YAML file").argument("<workfl
3010
3351
  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
3352
  process.exitCode = await resumeCommand(runId, options.adapter ? requireAdapterId(options.adapter) : void 0);
3012
3353
  });
3354
+ 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) => {
3355
+ process.exitCode = await cancelCommand(runId);
3356
+ });
3013
3357
  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
3358
  process.exitCode = await statusCommand(runId);
3015
3359
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lchase/yak",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "An agentic workflow engine",
5
5
  "license": "MIT",
6
6
  "type": "module",